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/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.onboardingFinished === false, complete mobile onboarding withPOST /profile/onboarding/complete. - 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/onboarding/complete | Finish onboarding with optional draft 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 | /communications/detail | Push communication detail/preview by query id. |
GET | /communications/detail/:id | Push communication detail/preview by path id. |
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'
| '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
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. - 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/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; complete onboarding through/profile/onboarding/complete.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/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:
- 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?.
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/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. 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:
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 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.
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.