Android client and protocol reference for SEAtS attendance systems. The app displays timetables and check-in codes and submits immediate, past or scheduled check-ins. Base application is linked with University of Nottingham, however can be modified for any other establishment.
| Future codes | Scheduled check-in |
|---|---|
![]() |
![]() |
| Official app after check-in | Android emulator |
|---|---|
![]() |
![]() |
| Expired check-in | Yesterday check-in |
|---|---|
![]() |
![]() |
- Install SeatsUnlocked-v1.11-beta.apk (or go to Releases).
- Sign in with your university Microsoft account.
- Tap Sync all codes and sign in to the tenant portal.
- Open a class and tap Inject to Seats or Copy code. Check-ins are sent immediately during the window, backdated for past classes, or scheduled for future classes.
| Area | Result |
|---|---|
| Live-code algorithm | MurmurHash2 x86_32 + Damm check digit over seed + counter |
| Request fingerprint | HMAC-SHA256, big-endian, key derived from the tenant MobilePhone setting |
| Student API | OIDC/PKCE, events, settings, clock, check-in |
| Portal path | Microsoft SAML session + QrProxy/GetCheckInCode static Class Code |
| Attendance semantics | timestamp-driven clocking, window enforcement, status mapping |
| Future check-in | no student-accessible immediate path found; schedule at the window |
https://idv2.seats.cloud/connect/authorize
?client_id=seatsmaui.code.pkce
&redirect_uri=seatsmobileapp://callback
&response_type=code
&scope=openid profile email SEAtSMobile SEAtS.profile offline_access
&code_challenge=<S256>
&code_challenge_method=S256
&state=<random>
&nonce=<random>
&login_hint=<university email>
The browser returns seatsmobileapp://callback?code=...&state=...; the app exchanges the code at
/connect/token and reads /connect/userinfo. MobileGwEndpoint selects the tenant's mobile
API host.
| Tenant | client_id |
|---|---|
| University of Nottingham | seatsmaui.code.pkce |
| Default / other tenants | seatsmaui.code.pkce.v2 |
Used when the tenant returns a non-empty checkinCode seed in the event payload.
counter = floor((now - startCutoff) / duration)
value = seed + counter
hash = MurmurHash2_x86_32(int64_le(value), 0xC58F1A7A)
head = hash % 100000
code = head + DammDigit(head)
duration is AutorefreshingQrDurationSecs (20 s at UoN). The QR payload is
https://seatssoftware.com/qr/<code>; the official QrParser accepts any host with the path
/qr/<6 digits>.
Test vectors, seed 832105:
| Counter | Code |
|---|---|
| 0 | 980580 |
| 1 | 719256 |
| 12345 | 183613 |
| 999999 | 165845 |
Reference implementations: seats_code.py,
SeatsCode.java. Damm self-check: 855824 is valid.
key = SHA256(hex(MobilePhone) || 0D92EC24CEDDE505)[0:8]
digest = HMAC-SHA256(key, message)
fp = big-endian uint64 of digest[0:8]
message = Timestamp|TimetableId|StudentScheduleId|CheckInReason|CheckInInput|MobileId|StudentId
Timestamp and StartCutoffTime are UTC yyyy-MM-ddTHH:mm:ss without a zone suffix. Empty
values are skipped. StartCutoffTime, RoomsIds, IsCheckOut and coordinates are not in the
CheckInWithCode fingerprint map. StudentId is appended last.
The property order is derived from SHA-256 prefixes of the DTO property names:
| Property | SHA-256 prefix |
|---|---|
Timestamp |
115A2CC9 |
TimetableId |
832C3C79 |
StudentScheduleId |
8E764926 |
CheckInReason |
5B483E37 |
CheckInInput |
D7BEF11C |
Uuid |
437562BB |
MobileId |
E230C682 |
RoomsIds |
AEF55036 |
Devices |
4BA5121D |
StudentId |
517B2762 |
Test vector:
message = "2026-10-22T12:00:00|1234567|7654321|Qr|302711|device-id|12345"
MobilePhone = "0011223344556677"
fp = 10686439190112768140
Reference: fingerprint.py.
A plain
SHA256(message || key)with little-endian output is wrong and returns400 Inconsistent parameters. This was the hardest bug in the project.
The mobile API returns "checkinCode": "" because QrCloudVerificationEnabled=true. The web
portal still exposes a static Class Code:
GET https://<tenant>.seats.cloud/api/QrProxy/GetCheckInCode?id=<timetableId>
→ 200 "302711"
The Angular lecturer page is guarded client-side by security(Lectures, QrCode), but the API
only checks that a portal session exists. The app signs into the portal in a WebView and runs a
same-origin fetch, so the HttpOnly session cookie is attached automatically:
fetch('/api/QrProxy/GetCheckInCode?id=' + timetableId, { credentials: 'include' })
.then(r => r.text())
.then(code => bridge.bulk(JSON.stringify({ [timetableId]: code })));The value is static for the whole lesson. It was observed unchanged over 30 seconds, after the
lecture and for future lectures (six were fetched in advance; 452290 for a 15:00 lecture was
accepted at 14:16).
The portal's Live Code tab is a separate presentation layer: it calls
QrProxy/GetQrCodeIdentifier every 20 seconds and renders
/qrProxy/getFile?id=<identifier>&isRoom=false. With the raw TimeZoneInfo JSON string
(not the parsed object) both SetRemainder and GetQrCodeIdentifier return 200 for a future
timetable, but the returned identifier (e.g. 577760) is not a valid check-in code
(isValid:false) and the image endpoint is 403/404. They do not open the attendance window.
Bulk sync. The app loops over every upcoming timetableId inside one WebView session and
caches the codes in SharedPreferences. The first schedule load without codes triggers the same
flow once; Sync all codes reruns it manually.
The server creates a clocking; a background processor applies it using the clocking's
Timestamp and the timetable window (startCutoff–endCutoff):
| Situation | Result |
|---|---|
| Inside the window, timestamp = now | Applied; swipeInDateTime set; event becomes Attended |
| Past lecture, timestamp inside its old window | Applied in ~15 s; event becomes Attended |
| Future lecture, timestamp in the future | Clocking stays unprocessed; event remains Scheduled |
| Future lecture, timestamp = now | Queued but outside the future window; not applied |
| Past lecture, timestamp after the window | Queued; attendance is not applied |
isValid: true only means the code is correct. Attendance is confirmed by
studentSchedule.swipeInDateTime (or status 2/3/7), not by top-level success or isValid.
The app therefore sends immediately inside the window, backdates start + 5 minutes for a past
lecture, and schedules a future lecture with AlarmManager at startCutoff + 60 seconds.
Status ids: 1 Absent, 2 Late, 3 Attended, 4 Scheduled, 5 MarkedAsAbsent,
6 MarkedAsLate, 7 MarkedAsAttended, 8 AuthorisedAbsent, 9 Cancelled,
10 AttendedOtherLecture, 11 Unexpected, 12 SwipedElsewhere, 13 Unscheduled.
Base URL: MobileGwEndpoint, e.g. https://01v2mobileapi.seats.cloud.
| Method | Path | Use |
|---|---|---|
| GET | /api/v3/students/myself/events?startDate=<unix>&endDate=<unix> |
schedule |
| GET | /api/v3/students/myself/events/{eventType}/{eventId} |
event detail |
| GET | /api/v1/app/settingsextended?appVersion=4.21.0 |
tenant settings |
| GET | /api/v1/app/clock?clientClock=<ms> |
server clock |
| POST | /api/v2/students/myself/checkinwithcode?fp=<fp> |
QR check-in |
| POST | /api/v2/students/myself/checkin?fp=<fp> |
no-code/beacon path (QR rejected) |
| POST | /api/v2/students/myself/unexpectedcheckin?fp=<fp> |
unexpected QR (requires active code) |
| POST | /api/v2/students/myself/startfinishclass |
manual open (disabled at UoN) |
| POST | /api/v1/students/myself/attended?eventType=&eventId= |
mark attended (returns false for ScheduledClass) |
| Flag | Meaning |
|---|---|
fp |
HMAC fingerprint |
startDate, endDate |
unix seconds |
appVersion |
4.21.0 |
clientClock |
client unix milliseconds |
eventType, eventId |
ScheduledClass + student schedule id |
id |
QrProxy timetable/room/QR identifier |
isRoom |
true for a room QR |
isNewUi |
Angular timetable UI |
login_hint |
email prefill |
code_challenge, state, nonce |
PKCE / CSRF |
credentials: include |
sends portal HttpOnly cookies |
Tested on 2026-10-24 against a future lecture (timeTableId=2884125, studentScheduleId=113655639,
code 215378). Every CheckInReason value was tried: Qr, QrUnsch, QrRoom, Manual,
Unexpected, Ibeacon, Bluetooth, Classroom, LiveQr, Live.
| Attempt | Result |
|---|---|
All ten CheckInReason values |
All valid codes, all remain Scheduled |
checkin without code (Manual, Ibeacon, Bluetooth) |
500; QR reasons explicitly rejected |
checkout / checkoutwithcode |
400 / 404 |
Timestamp formats (Z, +01:00, milliseconds, naive UTC/local, cutoff vs start+300) |
All unprocessed |
ignorePermissions=true / =1 |
500 ClaimsPrincipalPermission |
ChangeBulkAttendanceStatusToBefore |
500 ClaimsPrincipalPermission |
CalendarApi/UpdateStudentEvent |
500 NullReferenceException |
TimetableApi/MoveClass |
403 Forbidden |
ad-hoc QR /api/adhocqr/create |
404; gateway version 401 |
Gateway Account, SetOpenToCheckIn, GetStudentClockings |
403 CORS / 401 |
QrProxy/SetRemainder + GetQrCodeIdentifier |
200 for the QR presentation; identifier not a valid check-in code |
MarkAsAttended, startfinishclass, unexpectedcheckin |
false / disabled / invalid future code |
Conclusion for the UoN tenant: there is no student-accessible way to mark a future lesson
attended immediately. The reliable path is sending the check-in during the actual window, which
the app automates. This is not a claim that SEAtS fixed the issue globally: other tenants may
expose checkinCode in the event payload, in which case the seed/TOTP path still works. Test
each tenant separately.
- Download the official
.xapk(packagecom.seatssoftware.mobile4) from APKPure. - Extract the MAUI payload and open
SeatsMobile.dllin ILSpy. The C# app code is not obfuscated. - Search for
GetCurrentAutorefreshingQr,MurmurHash,Damm,QrParser,QrCheckinCode. - Open
Seats.Gateway.Mobile.Client.dlland search forStudentV2ApiClient,HMACSHA256,checkinwithcode. Gateway strings are XOR-encoded (byte ^ index ^ 0xAA); the fingerprint property order is a list of SHA-256 property-name prefixes. - For the portal, fetch the
/angular/bundles, prettify them, and search forQrProxy,GetCheckInCode,GetQrCodeIdentifier,ChangeBulkAttendanceStatus. - Verify every finding against the live server and read the event status back; the UI can lie.
Other tools: jadx for DEX, mitmproxy for HTTPS (the official app has no certificate pinning), Playwright for the portal SQL/API probes, Chrome DevTools for the Angular bundles.
Ready-to-run research scripts live in tests/tools: login_mfa.py,
portal_login.py, probe_qrproxy.py.
They read credentials from SEATS_EMAIL / SEATS_PASSWORD or a gitignored
tests/tools/.state/credentials.txt.
- Check the mobile event payload: a non-empty
checkinCodemeans seed/TOTP mode; empty means the portal path. - Find the portal base: sign in to the institution's SEAtS portal, open DevTools → Network, and
read the origin of any
/api/QrProxy/...request. Change the default inPrefs.portalBase()(https://uon.seats.cloudis only the UoN default). - Find the client id from the authorize URL or
SEATS_CLIENT_ID; UoN usesseatsmaui.code.pkce, many tenants useseatsmaui.code.pkce.v2. - Check
/api/v1/app/settingsextended:QrCloudVerificationEnabled,AutorefreshingQrDurationSecs,ValidateRoomCodeEnabled,UnexpectedCheckInEnabled,GeofencingEnabled. - Adapt
PortalActivityif the portal uses a different SSO; the same-origin QrProxy fetch does not depend on the identity provider. - Verify the fingerprint with a real
isValid: trueresponse;MobilePhoneis tenant-specific. - Room codes: if
ValidateRoomCodeEnabled=true, a room-code picker is the next extension;Event.hasRoomCodes()andSeatsApi.reasonFor()already recognizeQrRoom. - Never publish the institution's binaries, tokens or personal screenshots.
Requires JDK 21, Android SDK 36 and the included Gradle wrapper. For a release build, configure a signing key in app/keystore.properties using the example file.
cd app
./gradlew assembleDebug
# For a signed release after configuring keystore.properties:
./gradlew assembleReleaseThe release APK is written to app/app/build/outputs/apk/release/app-release.apk. Signing keys and keystore.properties are gitignored.
app/— Android clientreverse/algorithm/— code and fingerprint reference implementationstests/tools/— research scriptsmedia/— screenshotsdist/— release APK
- A portal login may return HTTP 500 at
/Account/Acs; use Start over. - Scheduled alarms are cancelled by force-stop, disabling the app, clearing its data or rebooting. Battery optimisation may delay them.
- Failed offline scheduled requests are not retried; submit again from the event.
- Room-code and unexpected check-ins are not exposed in the UI.





