# Mobile API Quickstart

This is the practical starting point for a mobile developer building the first Meets client.

## Base URLs

Use the HTTPS API proxy for staging:

```bash
API_BASE_URL=https://meets.cc/api/backend
WEB_BASE_URL=https://meets.cc
OPENAPI=https://meets.cc/api/openapi.yaml
HTTP_EXAMPLES=https://meets.cc/api/mobile-api-examples.http
```

Direct backend ports are bound to localhost on the server for security. Mobile apps must use the HTTPS API base URL.

The downloaded OpenAPI file is a deployed byte-identical mirror of the canonical backend contract. Repository and live mobile gates reject it if deployment falls behind the canonical source.

## First Screens To Build

| Screen | API calls | Auth |
| --- | --- | --- |
| Events list | `GET /events?limit=20` | Anonymous |
| Event detail | `GET /events/{event_id}` | Anonymous |
| Event participants | `GET /events/{event_id}/participants?limit=30` | Anonymous for public events; Bearer for private access |
| Feed | `GET /feed?limit=20` | Anonymous, richer when signed in |
| Event discussion | `GET /events/{event_id}/posts` | Anonymous for public events |
| Public profiles | `GET /users/profiles?limit=20` | Anonymous |
| My profile | `GET /me` | Bearer access token |
| Join event | `POST /events/{event_id}/join` | Bearer access token |
| My calendar | `GET /calendar/entries` | Bearer access token |
| Notifications | `GET /notifications` | Bearer access token |

Start with anonymous discovery first, then add login, join, calendar and notifications.

Public profile discovery is curated. `GET /users/profiles` excludes automated e2e/smoke/test profiles and may differ from a direct `GET /users/{user_id}/profile` lookup for an exact user URL.

## First Integration Checklist

1. Build anonymous discovery with `GET /events`, `GET /events/{event_id}`, `GET /feed` and `GET /users/profiles`.
2. Add native Yandex LoginSDK through `POST /auth/yandex/mobile` (or phone auth) and store `refresh_token` in secure storage.
3. Keep the returned `access_token` in memory and send `Authorization: Bearer <access_token>` on protected calls.
4. Implement `POST /auth/refresh` with JSON body and atomically replace both rotated tokens.
5. Add join event, event-post likes/reposts, comments, calendar entries, notifications and 401 recovery.
6. Run the production-safe public/anonymous mobile smoke before handoff:

```bash
API_BASE_URL=https://meets.cc/api/backend WEB_BASE_URL=https://meets.cc API_CORS_ALLOWED_ORIGIN=https://meets.cc ./scripts/check-mobile-api-contract
```

Download runnable REST Client examples from `https://meets.cc/api/mobile-api-examples.http`.

## Auth Contract

Native mobile auth uses a short-lived opaque Bearer token:

- successful Yandex LoginSDK, phone/dev auth and `POST /auth/refresh` return `access_token`, `access_token_expires_at`, `token_type: "Bearer"`, `refresh_token` and refresh expiry `expires_at`;
- send `Authorization: Bearer <access_token>` on protected API requests;
- access tokens expire after 15 minutes by default; refresh proactively or after a `401`;
- `POST /auth/refresh` accepts `{"refresh_token":"..."}` and rotates both tokens, immediately invalidating the previous pair;
- browser/WebView clients remain compatible through the `vstrechi_refresh_token` HttpOnly cookie (`Secure` over HTTPS, `SameSite=Lax`).

`GET /calendar/entries` follows this same protected-endpoint contract: native clients
send the returned `access_token` as a Bearer credential. Do not put `refresh_token` in
the Authorization header; the refresh cookie is only the browser compatibility path.

For a native MVP:

1. Obtain an OAuth token from the official Yandex LoginSDK and exchange it through `POST /auth/yandex/mobile`; phone call auth remains an alternative.
2. Persist `refresh_token` in platform secure storage; keep `access_token` in memory where possible.
3. Send `Authorization: Bearer <access_token>` on protected API requests.
4. Call `POST /auth/refresh` with the stored refresh token and replace both local tokens only after a successful response.

Send the LoginSDK token only as `{"oauth_token":"..."}` in the HTTPS JSON request body. Never put it in the URL, request ID or client logs. The existing `/auth/yandex/start` and callback routes remain the separate cookie-backed web flow.

## Auth Errors And Session Rules

- `401` on a protected call may mean the access token expired. Attempt one synchronized refresh, retry the request once, then show the login gate if refresh also fails.
- `POST /auth/refresh` rotates access and refresh tokens; always replace both values from the response and prevent parallel refresh races.
- After `POST /auth/logout`, a repeated `GET /me` must return `401`; clear secure storage and the local profile.
- Browser/WebView clients must use an allowlisted origin. Native HTTP clients use the HTTPS API directly and are not limited by browser CORS.

Most API errors return JSON as `{"error":"code"}`. The mobile client should branch by HTTP status first, then use `error` for copy and diagnostics.

Generate a correlation ID for each HTTP attempt, send it as `X-Request-ID`, and retain the same value with the client-side API log. The backend echoes `X-Request-ID` in the response and writes it to structured access, phone-auth and SMS.RU provider logs. Never put phone numbers or tokens into the request ID.

| Status | Error code | Meaning | Client action |
| --- | --- | --- | --- |
| `400` | `invalid_request` | Malformed JSON, invalid filter, missing field or invalid enum value. | Keep user on the current screen and show validation near the field/action. |
| `401` | `unauthorized` | Missing/expired session, invalid refresh token or logout completed. | Clear local session state and show login gate. |
| `401` | `yandex_token_invalid` / `yandex_client_not_allowed` | Yandex rejected the token, omitted the subject, or issued it for a client outside the server allowlist. | Discard the LoginSDK token and restart Yandex login; do not retry the same token. |
| `403` | `forbidden` | User is authenticated but cannot perform this action. | Do not retry automatically; show a permission message and keep state unchanged. |
| `404` | `not_found` | Resource does not exist or is not visible to this user. | Show removed/not-found state and drop stale local references. |
| `409` | `username_taken` | Profile username conflict. | Keep profile form open and ask for another username. |
| `410` | `phone_challenge_expired` | The SMS.RU call-auth challenge expired before verification. | Stop polling and offer to start a new challenge. |
| `429` | `phone_rate_limited` | Too many phone-auth start attempts. | Stop automatic retries and show a retry-later state. |
| `429` | `yandex_rate_limited` | Local protection or Yandex rate-limited token verification. | Back off and start a fresh attempt later. |
| `422` | `yandex_profile_incomplete` | The Yandex profile has no default phone. | Explain that a phone is required and let the user update Yandex profile/login another way. |
| `503` | `phone_provider_unavailable` | SMS.RU Call Auth is disabled or temporarily unavailable. | Preserve the entered phone, show retry, and include `X-Request-ID` in support diagnostics. |
| `503` | `yandex_provider_unavailable` | Yandex timed out or returned an unavailable/malformed response. | Offer retry and retain only the request ID for diagnostics. |
| `5xx` | `internal_error` | Unexpected server failure. | Show retry affordance and roll back optimistic state if needed. |

Dev/staging auth can use:

```bash
curl -sS -X POST "$API_BASE_URL/auth/dev/start" \
  -H 'Content-Type: application/json' \
  --data '{"email":"mobile@example.test"}'

curl -sS -X POST "$API_BASE_URL/auth/dev/verify" \
  -H 'Content-Type: application/json' \
  --data '{"email":"mobile@example.test","code":"123456"}'
```

Phone call auth uses SMS.RU Call Auth in production. `POST /auth/phone/call/start` returns the number the user should call; `POST /auth/phone/call/status` returns `200 pending`, `200 verified` with the normal auth response, or `410 {"error":"phone_challenge_expired"}`. Invalid challenges return `401 phone_challenge_invalid`.

Native Yandex LoginSDK exchange:

```bash
curl -sS -X POST "$API_BASE_URL/auth/yandex/mobile" \
  -H 'Content-Type: application/json' \
  --data '{"oauth_token":"YANDEX_LOGINSDK_TOKEN"}'
```

## Smoke Check

Run this from the repo before handing the API to mobile development:

```bash
API_BASE_URL=https://meets.cc/api/backend WEB_BASE_URL=https://meets.cc API_CORS_ALLOWED_ORIGIN=https://meets.cc ./scripts/check-mobile-api-contract
```

The default `public` mode checks HTTPS health, public reads, OpenAPI, anonymous auth gates, allowlisted browser preflight and blocked foreign origins without requiring or enabling dev auth.

Use `MOBILE_API_AUTH_MODE=token MOBILE_API_ACCESS_TOKEN=...` to additionally validate authenticated `/me`, calendar entries and notifications with an existing disposable access token. This mode does not rotate or revoke the supplied session.

Use `MOBILE_API_AUTH_MODE=dev` only against an environment where dev auth is explicitly enabled. It validates dev-auth cookie security, JSON-body refresh rotation, authenticated DTOs and logout cleanup. Production keeps dev auth disabled.

Across these modes the runner validates the mobile-critical JSON shape for events, feed, public profiles, auth responses, `/me`, calendar entries and notifications.

Successful write-path coverage runs only in the disposable Docker integration project:

```bash
./scripts/test-backend-repositories-integration
```

That gate starts isolated PostgreSQL, Redis and an API process with dev auth enabled; then two Bearer users exercise profile update (including `avatar_url`), event publish/join/leave, manual calendar CRUD, invite acceptance and notification read state. Cleanup cancels all active artifacts and revokes both sessions. The runner itself rejects non-loopback targets and requires `MOBILE_API_MUTATION_CONFIRM=local-disposable`, so it cannot be aimed at production accidentally.

## Mobile Integration Examples

Runnable `.http` collection is served from `https://meets.cc/api/mobile-api-examples.http`.

| Flow | Request | Expected | Client action |
| --- | --- | --- | --- |
| Anonymous discovery | `GET /events?limit=20`, `GET /feed?limit=20` | `200`, `items[]`, `next_cursor`; feed also returns `scope` | Render event cards, cache cursors, keep login optional until a mutating action. |
| Event participants | `GET /events/{event_id}/participants?limit=30&cursor=...` | `200` with public identities, total/visible counts and opaque `next_cursor`; private outsiders receive `404` | Append items in response order. The total count may exceed the visible count because legacy incognito identities are never disclosed. |
| Native Yandex auth and secure storage | `POST /auth/yandex/mobile` with the LoginSDK OAuth token | `200` with the standard Meets auth response; safe Yandex-specific `4xx/503` errors otherwise | Store refresh securely, keep access in memory, discard the LoginSDK result after exchange. |
| Phone call auth and secure storage | `POST /auth/phone/call/start`, then `POST /auth/phone/call/status` | Start returns `call_phone_pretty`; status returns `200 pending`, `200 verified` with tokens, or `410 phone_challenge_expired` | Send a per-attempt `X-Request-ID`, ask the user to call, poll while pending, store refresh after verification, and restart only after an expired challenge. |
| Dev auth and secure storage | `POST /auth/dev/start`, `POST /auth/dev/verify` | `200`, `current_user`, `session_id`, access/refresh tokens and expiries | Persist refresh securely and use access as Bearer; dev auth stays disabled in production. |
| Refresh then load profile | `POST /auth/refresh`, then `GET /me` | Refresh returns the rotated access/refresh pair; `/me` returns current user | Atomically replace both tokens, then fetch `/me` with the new Bearer token. |
| Join event | `POST /events/{event_id}/join` with `{"participation_visibility":"public"}` | `200` with participant status `going`, or `401` when anonymous | Update event detail, `participant_count`, `viewer_going` and calendar affordance after success. |
| Event-post comments | `POST /events/{event_id}/posts/{post_id}/comments`, `PATCH /events/{event_id}/posts/{post_id}/comments/{comment_id}`, `DELETE /events/{event_id}/posts/{post_id}/comments/{comment_id}` | `201`, `200`, `204`; mutations require the comment author | Reconcile create/edit in place. After delete mark the item locally and confirm the tombstone on the next thread read; replies remain attached. |
| Event-post reactions | `POST`/`DELETE /events/{event_id}/posts/{post_id}/like`, `POST`/`DELETE /events/{event_id}/posts/{post_id}/repost` | `200` with aggregate count and current viewer state; all four mutations are idempotent | Replace local reaction state with the response. A repost records a relation and does not create a duplicate post. |
| Repost history | `GET /me/reposts?limit=20&cursor=...` | `200` with `items[].post`, `items[].reposted_at` and opaque `next_cursor` | Append pages in response order; stop when `next_cursor` is `null`. Only posts the user can still view are returned. |
| Update profile | `PATCH /me/profile` with changed profile fields | `200` with the complete current-user DTO | Replace the cached profile only after success; `avatar_url` accepts HTTPS URLs or `null`. |
| Manual calendar entry | `POST /calendar/entries`, `PATCH /calendar/entries/{entry_id}`, `DELETE /calendar/entries/{entry_id}` | `201`, `200`, `200`; cancel returns status `canceled` | Reconcile the returned entry into local calendar state after every mutation. |
| Calendar invites | `GET /calendar/invites?role=incoming&status=pending&limit=20`, `GET /calendar/invites/{invite_id}`, then `PATCH /calendar/invites/{invite_id}` | Pending lists include `entry` and optional `inviter_profile`; detail also includes `participants`; patch accepts `accepted`, `declined` and `tentative` | Render pending invites in the calendar by `entry.starts_at`; use the detail endpoint for the full invite screen with organizer and participant statuses. |
| Calendar and notifications | `GET /calendar/entries`, `GET /notifications?limit=20`, `POST /notifications/{notification_id}/read` | `200` for signed-in user; `401` for anonymous user | Show login gate on `401`; render empty states when `items[]` is empty and persist returned `read_at`. |
| Logout cleanup | `POST /auth/logout`, then `GET /me` | `204` on logout; next `/me` returns `401` | Clear secure storage, cookie jar and local user/profile state. |

## Mobile-Critical DTO Fields

| DTO | Endpoints | Fields |
| --- | --- | --- |
| `EventCardDTO` | `GET /events`, `GET /feed` | `event.id`, `event.title`, `event.starts_at`, `event.image_url`, `event.status`, `location.city`, `participant_count`, `feed_context.viewer_going` |
| `EventParticipantsDTO` | `GET /events/{event_id}/participants` | `items[].user_id`, `items[].display_name`, `items[].username`, `items[].avatar_url`, `items[].joined_at`, `participant_count`, `visible_participant_count`, `next_cursor` |
| `CurrentUserDTO` | `GET /me` | `user.id`, `user.email`, `profile.user_id`, `profile.display_name`, `profile.avatar_url`, `profile.city`, `profile.interests` |
| `AuthSessionDTO` | `POST /auth/yandex/mobile`, `POST /auth/phone/call/status`, `POST /auth/dev/verify` | `current_user`, `session_id`, `access_token`, `access_token_expires_at`, `token_type`, `refresh_token`, `expires_at` |
| `RefreshSessionDTO` | `POST /auth/refresh` | rotated `access_token`, `access_token_expires_at`, `token_type`, `refresh_token`, `expires_at`; call `GET /me` with the new Bearer token |
| `CalendarEntryDTO` | `GET /calendar/entries` | `id`, `owner_user_id`, `source`, `event_id`, `title`, `starts_at`, `status`, `location_text` |
| `NotificationDTO` | `GET /notifications` | `items[]`, `unread_count`, `items[].kind`, `items[].title`, `items[].body`, `items[].href`, `items[].read_at` |
| `EventPostCommentDTO` | `GET /events/{event_id}/posts`, comment create/update | `id`, `event_id`, `post_id`, `author_user_id`, `author`, `parent_comment_id`, `body`, `created_at`, `updated_at`, `deleted_at` |
| `EventPostReactionDTO` | thread reads, like/repost mutations | post `like_count`, `viewer_liked`, `repost_count`, `viewer_reposted`; mutation `post_id`, aggregate count and viewer flag |
| `EventPostRepostHistoryDTO` | `GET /me/reposts?limit=20&cursor=...` | `items[].post`, `items[].reposted_at`, nullable opaque `next_cursor` |
| `CalendarInviteDTO` | `GET /calendar/invites` | `invite.id`, `invite.status`, `invite.invited_by_user_id`, `entry.id`, `entry.title`, `entry.starts_at`, `entry.location_text`, `inviter_profile.user_id`, `inviter_profile.display_name`, `inviter_profile.username`, `inviter_profile.avatar_url` |
| `CalendarInviteDetailsDTO` | `GET /calendar/invites/{invite_id}` | top-level `id`, `status`, `entry_id`, `invited_by_user_id`, `entry.title`, `entry.starts_at`, `entry.location_text`, `inviter_profile.*`, `participants[].invite.status`, `participants[].profile.*` |

`inviter_profile` can be `null`; fall back to `invite.invited_by_user_id` and a generic inviter label when profile data is unavailable.

## Useful Curl Examples

```bash
curl -sS "$API_BASE_URL/healthz"
curl -sS "$API_BASE_URL/events?limit=20"
curl -sS "$API_BASE_URL/feed?limit=20"
curl -sS "$API_BASE_URL/users/profiles?limit=20"
curl -sS "$API_BASE_URL/events/EVENT_ID/participants?limit=30"
curl -sS "$WEB_BASE_URL/api/openapi.yaml"
```

Refresh with a native-stored token:

```bash
curl -sS -X POST "$API_BASE_URL/auth/refresh" \
  -H 'Content-Type: application/json' \
  --data '{"refresh_token":"REFRESH_TOKEN"}'
```

Authenticated request:

```bash
curl -sS "$API_BASE_URL/me" \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

Join an event:

```bash
curl -sS -X POST "$API_BASE_URL/events/EVENT_ID/join" \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  --data '{"participation_visibility":"public"}'
```

Read a private event participant page with the current viewer context:

```bash
curl -sS "$API_BASE_URL/events/EVENT_ID/participants?limit=30" \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

Pass `next_cursor` unchanged on the next request. `participant_count` includes
legacy incognito going rows; their identities are absent from `items` and
`visible_participant_count`.

Create, edit and delete an event-post comment:

```bash
curl -sS -X POST "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/comments" \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  --data '{"body":"Буду к началу"}'

curl -sS -X PATCH "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/comments/COMMENT_ID" \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  --data '{"body":"Буду за десять минут до начала"}'

curl -sS -X DELETE "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/comments/COMMENT_ID" \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

Only the comment author can edit or delete it. Delete returns `204`; subsequent thread reads keep a tombstone with non-null `deleted_at`, so replies are not lost.

Like, unlike, repost and unrepost:

```bash
curl -sS -X POST "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/like" \
  -H 'Authorization: Bearer ACCESS_TOKEN'

curl -sS -X DELETE "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/like" \
  -H 'Authorization: Bearer ACCESS_TOKEN'

curl -sS -X POST "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/repost" \
  -H 'Authorization: Bearer ACCESS_TOKEN'

curl -sS -X DELETE "$API_BASE_URL/events/EVENT_ID/posts/POST_ID/repost" \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

All four operations are idempotent and return the authoritative aggregate count plus
the current viewer flag. Repost keeps the original post and author; it does not create a
copied `event_posts` row.

Read the current user's repost history:

```bash
curl -sS "$API_BASE_URL/me/reposts?limit=20" \
  -H 'Authorization: Bearer ACCESS_TOKEN'
```

Pass the returned opaque `next_cursor` unchanged as the next request's `cursor`.

## Known Beta Limits

- Browser CORS is allowlist-based through `API_CORS_ALLOWED_ORIGINS`; staging allows `https://meets.cc`. Native HTTP clients are not limited by browser CORS.
- Payments are disabled in beta: `PAYMENTS_ENABLED=false`.
- Incognito attendance is hidden from public profile/social feed, but visible to the current user in their own going/calendar surfaces.
- The staging dataset may change during QA; do not hardcode event IDs.
- Direct backend ports are not public; use `https://meets.cc/api/backend` from mobile clients.
