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.

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.tpAccessGranted === false, show the Ticketportal grant UI before loading ticketing data.
  5. 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/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/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'
    | '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.
  • 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; it is read-only on this mobile API surface.
  • 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/avatar

Uploads or sets the profile avatar and returns:

{
  imageUrl: string | null;
}

Body must provide exactly one of:

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

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?.

Returns:

{
  items: Array<{
    id: string;
    kind:
      | 'purchase'
      | 'donation'
      | 'attendance'
      | 'ticket_cancellation'
      | 'season_ticket_return';
    occurredAt: 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';
      };
    } | 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[];
  }>;
  page: number;
  limit: number;
  total: number;
}

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. 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 request from purchaseRef.transactionId, ticketNumber, and ticketType. If detailTarget is null, there is no ticketing detail target for that activity item. The response is either ticket detail or season-ticket 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