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/v1Every request must include the current Keycloak access token:
Authorization: Bearer <keycloak-access-token>
Accept: application/jsonFor JSON writes, also send:
Content-Type: application/jsonRecommended startup sequence:
- Obtain/refresh a Keycloak access token in the mobile app.
- Call
GET /meto verify the token and get the user id/email. - Call
GET /profileto load the profile and feature flags/custom fields. - If
profile.tpAccessGranted === false, show the Ticketportal grant UI before loading ticketing data. - After consent, call
PUT /profile/tp-accesswith{ "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
| Method | Path | Purpose |
|---|---|---|
GET | /me | Token/auth probe. |
GET | /profile | Load profile and custom-field-derived flags. |
PUT | /profile | Update supported basic profile fields. |
POST | /profile/avatar | Upload/set profile avatar. |
DELETE | /profile/avatar | Delete profile avatar. |
PUT | /profile/tp-access | Grant Ticketportal data access. |
GET | /ticketing | Ticketing overview. |
GET | /ticketing/tickets | Active ticket list. |
GET | /ticketing/tickets/detail | Ticket detail. |
GET | /ticketing/season-tickets | Active season-ticket list. |
GET | /ticketing/season-tickets/inactive | Inactive season-ticket list. |
GET | /ticketing/season-tickets/detail | Season-ticket detail. |
GET | /ticketing/season-tickets/attendance | Season-ticket attendance entries. |
GET | /ticketing/history | Ticketing history list. |
GET | /ticketing/purchase-history | Ticketing purchase-history list. |
GET | /ticketing/activity-detail | Resolve a detail screen from an activity item. |
GET | /activity | Combined activity feed. |
GET | /communications | Communication/message list. |
GET | /communication-preferences | Marketing/system email preferences. |
PUT | /communication-preferences | Update marketing email preference. |
GET | /favorite-player | Current favorite-player selection. |
PUT | /favorite-player | Update favorite-player selection. |
GET | /favorite-player/options | Available favorite-player options. |
GET | /loyalty/summary | Loyalty status/points summary. |
POST | /loyalty/register | Register for loyalty. |
POST | /loyalty/leave | Leave/deactivate loyalty. |
GET | /loyalty/history | Loyalty points history. |
GET | /fanshop/orders | Fanshop order list. |
GET | /fanshop/orders/:orderId | Fanshop 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: AuthorizationCommon 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
403from 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/sessionfor 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/POSTendpoints that document a body. - List endpoints use
pageandlimitquery params unless noted otherwise.- Defaults:
page=1,limit=50. - Max
limit:100.
- Defaults:
instanceis 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, andtpAccessGranted. favoritePlayerIdis exposed onAppProfile; update it through/favorite-player.tpAccessGrantedis exposed onAppProfile; grant it through/profile/tp-access.onboardingFinishedis exposed onAppProfile; it is read-only on this mobile API surface.mailingAllowedis read from profile custom data when present, but use/communication-preferencesfor marketing email consent updates.secondaryPhoneandjerseyNumberare currently accepted by thePUT /profilebody 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:
- Load
GET /profile. - If
tpAccessGrantedisfalse, show the user a consent screen explaining that ticketing data comes from Ticketportal. - On accept, call
PUT /profile/tp-access. - Use the returned profile to update local state, then call
/ticketingor 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 FORBIDDENwithTicketportal data access has not been granted: show the Ticketportal consent/grant flow and callPUT /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/summaryUse /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=50For 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:
transactionIdis required.ticketTypeis required whendetailTargetis present.ticketNumberis optional; omit it when it is absent. Do not send empty,null, orundefinedquery 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.
Related browser auth routes
Mobile clients normally call /api/app/v1/* with bearer tokens. Browser-based flows use cookie/session routes instead:
GET /auth/sessionGET /auth/loginGET /auth/callbackGET|POST /auth/logout
Do not rely on browser cookies for the mobile app API.