Internal Dev Docs

Mobile SSO API

Bearer-token API reference for mobile/app clients.

This document describes the bearer-token API used by mobile/app clients. The API lives under /api/app/v1/* and is separate from the browser cookie routes under /auth/*.

The goal of this document is to be usable by mobile frontend developers without reading the web app source. Type names are included for traceability, but each integration-critical shape is also shown inline.

Related internal docs:

Quick start for mobile apps

Production API base URL:

https://profil.slavia.cz/api/app/v1

Every request must include the current Keycloak access token:

Authorization: Bearer <keycloak-access-token>
Accept: application/json

For JSON writes, also send:

Content-Type: application/json

Recommended startup sequence:

  1. Obtain/refresh a Keycloak access token in the mobile app.
  2. Call GET /me to verify the token and get the user id/email.
  3. Call GET /profile to load the profile and feature flags/custom fields.
  4. If profile.onboardingFinished === false, complete mobile onboarding with POST /profile/onboarding/complete.
  5. If profile.tpAccessGranted === false, show the Ticketportal grant UI before loading ticketing data.
  6. After consent, call PUT /profile/tp-access with { "granted": true }, then load ticketing endpoints.

Example request:

curl 'https://profil.slavia.cz/api/app/v1/profile' \
  -H 'Authorization: Bearer <keycloak-access-token>' \
  -H 'Accept: application/json'

Endpoint summary

MethodPathPurpose
GET/meToken/auth probe.
GET/profileLoad profile and custom-field-derived flags.
PUT/profileUpdate supported basic profile fields.
POST/profile/onboarding/completeFinish onboarding with optional draft fields.
POST/profile/avatarUpload/set profile avatar.
DELETE/profile/avatarDelete profile avatar.
PUT/profile/tp-accessGrant Ticketportal data access.
GET/ticketingTicketing overview.
GET/ticketing/ticketsActive ticket list.
GET/ticketing/tickets/detailTicket detail.
GET/ticketing/season-ticketsActive season-ticket list.
GET/ticketing/season-tickets/inactiveInactive season-ticket list.
GET/ticketing/season-tickets/detailSeason-ticket detail.
GET/ticketing/season-tickets/attendanceSeason-ticket attendance entries.
GET/ticketing/historyTicketing history list.
GET/ticketing/purchase-historyTicketing purchase-history list.
GET/ticketing/activity-detailResolve a detail screen from an activity item.
GET/activityCombined activity feed.
GET/communicationsCommunication/message list.
GET/communications/detailPush communication detail/preview by query id.
GET/communications/detail/:idPush communication detail/preview by path id.
GET/communication-preferencesMarketing/system email preferences.
PUT/communication-preferencesUpdate marketing email preference.
GET/favorite-playerCurrent favorite-player selection.
PUT/favorite-playerUpdate favorite-player selection.
GET/favorite-player/optionsAvailable favorite-player options.
GET/loyalty/summaryLoyalty status/points summary.
POST/loyalty/registerRegister for loyalty.
POST/loyalty/leaveLeave/deactivate loyalty.
GET/loyalty/historyLoyalty points history.
GET/fanshop/ordersFanshop order list.
GET/fanshop/orders/:orderIdFanshop order detail.

Authentication

All endpoints require a Keycloak access token in the Authorization header:

Authorization: Bearer <keycloak-access-token>

The server validates the token with Keycloak userinfo and uses the token subject/email as the app principal. Missing, malformed, invalid, or expired bearer tokens return:

{ "code": "UNAUTHORIZED", "message": "Invalid or expired token" }

App API responses include:

Cache-Control: private, no-store, max-age=0
Vary: Authorization

Common error envelope:

type AppApiError = {
  code:
    | 'BAD_REQUEST'
    | 'FORBIDDEN'
    | 'INTERNAL_SERVER_ERROR'
    | 'NOT_FOUND'
    | 'PAYLOAD_TOO_LARGE'
    | 'UNAUTHORIZED';
  message: string;
};

Mobile handling guidance:

  • On 401, refresh the Keycloak access token if possible, then retry once. If refresh fails, send the user through login again.
  • On 403 from ticketing endpoints, check the error message. For Ticketportal access, show the grant flow and call /profile/tp-access.
  • On 400, treat the request as invalid input or an app bug; inspect the endpoint-specific validation rules.
  • On 413, the request body or uploaded asset is too large; ask the user to choose a smaller file instead of retrying unchanged.
  • App API routes are bearer-authenticated. Do not depend on browser cookies, CSRF tokens, or /auth/session for mobile calls.

Conventions

  • Production base URL: https://profil.slavia.cz/api/app/v1.
  • Base path on any deployed origin: /api/app/v1.
  • JSON request bodies are required for PUT/POST endpoints that document a body.
  • List endpoints use page and limit query params unless noted otherwise.
    • Defaults: page=1, limit=50.
    • Max limit: 100.
  • instance is an optional ticketing integration selector where documented.
  • Ticketing endpoints require Ticketportal data access; see Ticketportal access.

Auth probe

GET /api/app/v1/me

Returns the minimal authenticated principal.

Response:

{
  authenticated: true;
  email: string | null;
  userId: string;
}

Profile

GET /api/app/v1/profile

Returns the profile.

type AppProfile = {
  id: string;
  email: string;
  firstName: string | null;
  lastName: string | null;
  fullName: string;
  gender: string | null;
  address: string | null;
  city: string | null;
  zip: string | null;
  countryCode: string | null;
  birthDate: string | null;
  primaryPhone: string | null;
  secondaryPhone: string | null;
  jerseyNumber: string | null;
  imageUrl: string | null;
  favoritePlayerId: string | null;
  onboardingFinished: boolean;
  tpAccessGranted: boolean;
  mailingAllowed: boolean;
  isPersisted: boolean;
};

PUT /api/app/v1/profile

Updates the profile and returns AppProfile.

Body:

{
  firstName: string;
  lastName: string;
  birthDate: string;
  gender: "0" | "1" | "2";
  jerseyNumber: string;
  countryCode: string;
  primaryPhone: string;
  secondaryPhone: string;
  address: string;
  city: string;
  zip: string;
  mailingAllowed?: boolean;
}

Invalid body returns 400 { code: "BAD_REQUEST", message: "Invalid profile body" }.

Profile custom-field notes:

  • Supported profile-service custom fields are favoritePlayerId, onboardingFinished, and tpAccessGranted.
  • favoritePlayerId is exposed on AppProfile; update it through /favorite-player.
  • tpAccessGranted is exposed on AppProfile; grant it through /profile/tp-access.
  • onboardingFinished is exposed on AppProfile; complete onboarding through /profile/onboarding/complete.
  • mailingAllowed is read from profile custom data when present, but use /communication-preferences for marketing email consent updates.
  • secondaryPhone and jerseyNumber are currently accepted by the PUT /profile body schema, but the profile-service update mapper does not persist them.

POST /api/app/v1/profile/onboarding/complete

Completes mobile onboarding and returns the updated AppProfile. Mobile onboarding should use this endpoint instead of PUT /profile; firstName and lastName are not required here.

Body may be {} or omitted. Optional body fields:

{
  profile?: {
    birthDate: string;
    gender: "0" | "1" | "2";
    countryCode: string;
    primaryPhone: string;
    address: string;
    city: string;
    zip: string;
  };
  favoritePlayerId?: string | null; // numeric string, 1-16 digits; null means skipped/no selection
}

Example completion request:

curl -X POST 'https://profil.slavia.cz/api/app/v1/profile/onboarding/complete' \
  -H 'Authorization: Bearer <keycloak-access-token>' \
  -H 'Content-Type: application/json' \
  -d '{"profile":{"birthDate":"1990-04-03","gender":"1","countryCode":"CZ","primaryPhone":"+420123456789","address":"Na Prikope 1","city":"Praha","zip":"11000"},"favoritePlayerId":"12345"}'

Invalid body returns 400 { code: "BAD_REQUEST", message: "Invalid onboarding body" }. Upstream profile-service validation rejection returns 400 { code: "BAD_REQUEST", message: "Onboarding completion rejected" }.

POST /api/app/v1/profile/avatar

Uploads or sets the profile avatar and returns:

{
  imageUrl: string | null;
}

Body must provide exactly one of:

{
  base64: string;
}
// or
{
  url: string;
}

base64 uploads must be PNG or JPEG data URLs. Invalid avatar bodies return 400 { code: "BAD_REQUEST", message: "Invalid profile avatar body" }; rejected avatar data returns 400 { code: "BAD_REQUEST", message: "Avatar upload rejected" }; oversized avatar data returns 413 { code: "PAYLOAD_TOO_LARGE", message: "Avatar upload too large" }.

DELETE /api/app/v1/profile/avatar

Deletes the profile avatar and returns:

{
  imageUrl: string | null;
}

Ticketportal access

Ticketing endpoints first check whether the profile has granted Ticketportal data access. If not granted, the endpoint returns:

{
  "code": "FORBIDDEN",
  "message": "Ticketportal data access has not been granted"
}

PUT /api/app/v1/profile/tp-access

Grants Ticketportal data access and returns the updated AppProfile.

Body:

{
  granted: true;
}

Example grant request:

curl -X PUT 'https://profil.slavia.cz/api/app/v1/profile/tp-access' \
  -H 'Authorization: Bearer <keycloak-access-token>' \
  -H 'Content-Type: application/json' \
  -d '{"granted":true}'

Suggested mobile Ticketportal flow:

  1. Load GET /profile.
  2. If tpAccessGranted is false, show the user a consent screen explaining that ticketing data comes from Ticketportal.
  3. On accept, call PUT /profile/tp-access.
  4. Use the returned profile to update local state, then call /ticketing or list endpoints.

Ticketing

All ticketing endpoints accept optional instance unless noted otherwise. If the app only targets Slavia, omit instance; the server resolves the default supported ticketing instance.

Important mobile states:

  • 401 UNAUTHORIZED: refresh/re-authenticate the Keycloak token.
  • 403 FORBIDDEN with Ticketportal data access has not been granted: show the Ticketportal consent/grant flow and call PUT /profile/tp-access.
  • 400 BAD_REQUEST: query/body validation error; do not retry without changing input.
  • 404 NOT_FOUND: referenced ticket/order/detail is not available for this user.

Core ticketing item fields used by list/detail screens:

type AppTicketingTicket = {
  kind: 'ticket';
  id: string;
  title: string | null;
  holderName: string | null;
  ticketType: string | null;
  performance: string;
  performanceDateText: string | null;
  performanceBegin: string | null;
  performanceEnd: string | null;
  transactionDate: string | null;
  venueName: string | null;
  venueInfo: string | null;
  venueAddress: string | null;
  entrance: string | null;
  sector: string | null;
  row: string | null;
  seat: string | null;
  price: string | null;
  ticketPrice: string | null;
  barcode: string | null;
  barcodeShowAfter: string | null;
  match: AppTicketingMatch | null;
  visual: AppTicketingVisual | null;
  detailRef: {
    kind: 'ticket';
    transactionId: number;
    ticketId: number;
    seasonTicketNumber: null;
  } | null;
  detailState: 'resolved' | 'unresolved';
  performanceCategory: string | null;
};

type AppTicketingSeasonTicket = Omit<
  AppTicketingTicket,
  'kind' | 'detailRef' | 'performanceCategory'
> & {
  kind: 'season_ticket';
  detailRef: {
    kind: 'season_ticket';
    transactionId: number;
    ticketId: number;
    seasonTicketNumber: string | null;
  } | null;
  status: 'verified' | 'inactive' | 'failed' | 'verifying' | 'transferring';
  displayId: string | null;
  titleSeason: string | null;
  selectedVisual: { id: string | null; isDark: boolean | null } | null;
  verificationCode: string | null;
  isValid: boolean | null;
  isValidating: boolean | null;
  isTransferring: boolean | null;
  isValidForNextMatch: boolean | null;
  nextMatch: AppTicketingMatch | null;
  issuedTo: AppTicketingIssuedTo | null;
  attendanceSummary: {
    total: number | null;
    visit: number | null;
    return: number | null;
    miss: number | null;
    waiting: number | null;
    maxMiss: number | null;
  } | null;
  attendanceEntries: AppTicketingAttendanceEntry[];
};

type AppTicketingMatch = {
  id: string | null;
  title: string;
  startsAt: string | null;
  teamBadge: string | null;
  competitionLabel: string | null;
  homeTeam: {
    id: string | null;
    name: string | null;
    shortName: string | null;
    logoUrl: string | null;
  } | null;
  awayTeam: {
    id: string | null;
    name: string | null;
    shortName: string | null;
    logoUrl: string | null;
  } | null;
};

type AppTicketingVisual = {
  cardBackgroundUrl: string | null;
  detailBackgroundUrl: string | null;
  detailBannerUrl: string | null;
  partnerBannerUrl: string | null;
  isDark: boolean | null;
};

For season-ticket list endpoints, items omit attendanceEntries; load attendance separately via /ticketing/season-tickets/attendance.

GET /api/app/v1/ticketing

Query params:

{
  instance?: string;
  section?: "all" | "overview" | "tickets" | "season_tickets";
}

Returns overview ticketing data:

{
  state: "ready" | "empty" | "error";
  tickets: AppTicketingTicket[];
  seasonTickets: AppTicketingSeasonTicket[];
  message: string | null;
}

GET /api/app/v1/ticketing/tickets

Query params: instance?, page?, limit?.

Returns:

{
  items: AppTicketingTicket[];
  page: number;
  limit: number;
  total: number;
  detailSummary: { resolved: number; unresolved: number };
}

GET /api/app/v1/ticketing/tickets/detail

Query params:

{
  instance?: string;
  transactionId: number;
  ticketId: number;
}

Returns AppTicketingTicketDetail, or 404 if not found. Passing seasonTicketNumber is invalid for this endpoint.

GET /api/app/v1/ticketing/season-tickets

Query params: instance?, page?, limit?.

Returns active season tickets:

{
  items: AppTicketingSeasonTicketListItem[];
  page: number;
  limit: number;
  total: number;
  detailSummary: { resolved: number; unresolved: number };
}

GET /api/app/v1/ticketing/season-tickets/inactive

Query params: instance?, page?, limit?.

Returns inactive season tickets in the same list envelope as active season tickets.

GET /api/app/v1/ticketing/season-tickets/detail

Query params:

{
  instance?: string;
  transactionId: number;
  ticketId: number;
  seasonTicketNumber?: string | null;
}

Returns AppTicketingSeasonTicketDetail, or 404 if not found.

GET /api/app/v1/ticketing/season-tickets/attendance

Query params:

{
  instance?: string;
  seasonTicketNumber: string;
  page?: number;
  limit?: number;
}

Returns paginated season-ticket attendance entries:

{
  items: AppTicketingAttendanceEntry[];
  page: number;
  limit: number;
  total: number;
}

Attendance entry shape:

type AppTicketingAttendanceEntry = {
  id: string;
  performance: string | null;
  performanceDate: string | null;
  venueName: string | null;
  venueInfo: string | null;
  returnType: string | null;
  returnReason: string | null;
  didReturn: boolean | null;
  didVisit: boolean | null;
  didReturnLastChance: boolean | null;
  didVisitLastChance: boolean | null;
  wasGifted: boolean | null;
  isWaitingForData: boolean | null;
  firstEnter: string | null;
  seat: string | null;
  row: string | null;
  entrance: string | null;
  sector: string | null;
  sectorFull: string | null;
  match: AppTicketingMatch | null;
};

GET /api/app/v1/ticketing/history

Query params: instance?, page?, limit?.

Returns paginated ticketing history items:

{
  items: AppTicketingHistoryItem[];
  page: number;
  limit: number;
  total: number;
}

GET /api/app/v1/ticketing/purchase-history

Query params: instance?, page?, limit?.

Returns paginated purchase-history items:

{
  items: AppTicketingPurchaseHistoryItem[];
  page: number;
  limit: number;
  total: number;
}

GET /api/app/v1/ticketing/activity-detail

Resolves a ticketing detail from an activity item.

Query params:

{
  instance?: string;
  transactionId: number;
  ticketNumber?: string | null;
  ticketType: "ticket" | "season_ticket" | "package_ticket";
}

Returns either AppTicketingTicketDetail or AppTicketingSeasonTicketDetail, or 404 if not found.

Activity

GET /api/app/v1/activity

Query params: page?, limit?.

Activity is sorted by occurredAt descending before pagination. Fanshop order activity is best-effort and includes the most recent customer-scoped fanshop order-list page, currently up to 50 orders.

Returns:

{
  items: Array<{
    id: string;
    kind:
      | 'purchase'
      | 'donation'
      | 'attendance'
      | 'ticket_cancellation'
      | 'season_ticket_return'
      | 'fanshop_order_created';
    occurredAt: string;
    /** Backwards-compatible alias of occurredAt for mobile clients. */
    sentAt: string;
    ticketType: 'ticket' | 'season_ticket' | 'package_ticket';
    performance: string;
    startsAt: string | null;
    orderNumber: string | null;
    ticketNumber: string | null;
    purchaseRef: { transactionId: number; orderNumber: string | null } | null;
    detailTarget:
      | {
          query: {
            transactionId: number;
            ticketNumber?: string;
            ticketType: 'ticket' | 'season_ticket' | 'package_ticket';
          };
        }
      | {
          kind: 'fanshop_order';
          orderId: number;
        }
      | null;
    donationDirection: 'sent' | 'received' | null;
    counterparty: string | null;
    price: number | null;
    saleChannel: string | null;
    venueName: string | null;
    venueCity: string | null;
    sector: string | null;
    row: string | null;
    seat: string | null;
  }>;
  page: number;
  limit: number;
  total: number;
}

Communications

GET /api/app/v1/communications

Query params: page?, limit?.

Returns paginated communication items:

{
  items: Array<{
    id: string;
    channel: 'push' | 'sms' | 'email';
    sentAt: string | null;
    status: 'read' | 'sent';
    campaignName: string | null;
    title: string;
    summary: string | null;
    body: string[];
    link: string | null;
    linkType: 'App' | 'UrlExternal';
  }>;
  page: number;
  limit: number;
  total: number;
}

GET /api/app/v1/communications/detail

Query params: id.

GET /api/app/v1/communications/detail/:id

Path params: id.

Both forms return push communication preview/detail data:

{
  channel: 'push';
  campaignName: string | null;
  title: string;
  bodyHtml: string | null;
  imageId: string | null;
  imageUrl: string | null;
  externalUrl: string | null;
  link: string | null;
  linkType: 'App' | 'UrlExternal';
}

Invalid or missing ids return 400 { code: "BAD_REQUEST", message: "Invalid communication detail id" }.

GET /api/app/v1/communication-preferences

Returns:

{
  systemEmailsEnabled: true;
  marketingEmailsEnabled: boolean;
}

PUT /api/app/v1/communication-preferences

Body:

{
  marketingEmailsEnabled: boolean;
}

Returns the updated communication preferences.

Favorite player

GET /api/app/v1/favorite-player

Returns:

{
  playerId: string | null;
}

PUT /api/app/v1/favorite-player

Body:

{
  playerId: string | null;
}

playerId, when present, must be a 1-16 digit string. Returns the updated selection.

GET /api/app/v1/favorite-player/options

Query params: page?, limit?.

Returns:

{
  items: Array<{
    id: string;
    name: string;
    position: string | null;
    number: number | null;
    imageUrl: string | null;
  }>;
  page: number;
  limit: number;
  total: number;
}

Loyalty

GET /api/app/v1/loyalty/summary

Returns:

{
  isRegistered: boolean;
  isKnown: boolean;
  isActive: boolean;
  memberCode: string | null;
  qrCodeValue: string | null;
  points: number | null;
  program: { id: string | null; name: string | null } | null;
  level: { id: string | null; name: string | null } | null;
}

POST /api/app/v1/loyalty/register

Registers the user for loyalty and returns the updated loyalty summary.

POST /api/app/v1/loyalty/leave

Leaves/deactivates loyalty and returns the updated loyalty summary.

GET /api/app/v1/loyalty/history

Query params: page?, limit?.

Returns:

{
  items: Array<{
    id: string;
    occurredAt: string | null;
    title: string;
    description: string | null;
    pointsDelta: number | null;
    balanceAfter: number | null;
  }>;
  page: number;
  limit: number;
  total: number;
}

Fanshop orders

GET /api/app/v1/fanshop/orders

Query params: page?, limit?.

Returns app-mapped fanshop orders:

{
  items: AppFanshopOrderSummary[];
  page: number;
  limit: number;
  total: number;
}

AppFanshopOrderSummary uses camelCase fields:

{
  id: number;
  orderNumber: string;
  date: string;
  updatedAt?: string | null;
  totalPrice: number;
  totalPriceWithTax: number;
  status: string;
  statusLabel: string;
  paymentMethod: string | null;
  deliveryMethod: string | null;
  invoiceUrl: string | null;
  customer: {
    name: string | null;
    email: string | null;
    ic: string | null;
    dic: string | null;
    deliveryAddress: {
      name: string | null;
      street: string | null;
      streetNumber: string | null;
      city: string | null;
      zip: string | null;
      country: string | null;
      countryIso: string | null;
    } | null;
  };
  productImages: string[];
}

GET /api/app/v1/fanshop/orders/:orderId

Path params:

{
  orderId: number;
}

Returns AppFanshopOrderSummary plus order items:

AppFanshopOrderSummary & {
  items: Array<{
    sku: string;
    name: string;
    description: string | null;
    imageUrl: string | null;
    quantity: number;
    unitPrice: number;
    unitPriceWithTax: number;
    taxRate: number;
  }>;
}

Invalid IDs return 400; missing orders return 404.

Common mobile integration flows

App startup/profile

GET /me
GET /profile
GET /communication-preferences
GET /favorite-player
GET /loyalty/summary

Use /me only as a lightweight token probe. Use /profile as the source for display name, avatar URL, onboarding status, favorite-player id, and Ticketportal grant state.

Ticketing tab

GET /profile
if tpAccessGranted === false:
  PUT /profile/tp-access { "granted": true }
GET /ticketing?section=overview
GET /ticketing/tickets?page=1&limit=50
GET /ticketing/season-tickets?page=1&limit=50

For detail screens, prefer the detailRef fields returned by list/history endpoints instead of constructing ids manually.

Activity detail screen

Activity items with a ticketing detail include detailTarget.query. Fanshop order-created items include detailTarget: { kind: 'fanshop_order', orderId }; open them with GET /api/app/v1/fanshop/orders/:orderId.

To open the related ticketing detail, call GET /api/app/v1/ticketing/activity-detail with the fields from detailTarget.query:

  • transactionId is required.
  • ticketType is required when detailTarget is present.
  • ticketNumber is optional; omit it when it is absent. Do not send empty, null, or undefined query values.
const params = new URLSearchParams({
  transactionId: String(activity.detailTarget.query.transactionId),
  ticketType: activity.detailTarget.query.ticketType,
});

if (activity.detailTarget.query.ticketNumber) {
  params.set('ticketNumber', activity.detailTarget.query.ticketNumber);
}

const url = `/api/app/v1/ticketing/activity-detail?${params}`;

Older clients can construct the same ticketing request from purchaseRef.transactionId, ticketNumber, and ticketType. If detailTarget is null, there is no detail target for that activity item. Ticketing detail responses are either ticket detail or season-ticket detail; fanshop detail responses are order detail.

Mobile clients normally call /api/app/v1/* with bearer tokens. Browser-based flows use cookie/session routes instead:

  • GET /auth/session
  • GET /auth/login
  • GET /auth/callback
  • GET|POST /auth/logout

Do not rely on browser cookies for the mobile app API.

On this page