TT Festival timetable — server-side data & live API access
Scope: how the festival programme (timetable) is delivered, how it is reachable outside the app, the auth gate that protects it, the techniques used to discover and reproduce that access, and the open tooling we built on top of it (a live, auto-refreshing visualiser in a separate repo).
Ethics / consent: all work below was performed on an owner-consented, self-owned device and against a public backend, reading only the public event programme. No accounts were attacked and no personal data was accessed. Secret-looking values are redacted in this document to avoid re-publishing them in git.
1. Key findings (summary)
- The timetable is not baked into the app. It is server-side REST data from the white-label Appmiral backend, downloaded at runtime and merely visualised on the device. The same data also sits in the app’s on-device SQLite cache.
- The read API is reachable from anywhere, but is gated by a single static
header:
x-protect. Without it the API returns403 Missing authentication; with the app’s hardcoded value it returns200 OK. - That
x-protectvalue is a hardcoded app secret shipped in the APK (res/values/strings.xml→x_Protect). It is not a per-user or per-session token — it is a shared, static string that gates every client of this tenant. The same header also unlocks other Appmiral modules (e.g. the games / bierroute endpoint). - There is no certificate pinning and CORS is wide open (
Access-Control-Allow-Origin: *), so the programme can be pulled withcurl, a Python script, or even a browserfetch()from any origin. - Net effect: the
x-protect“authentication” provides no meaningful protection for the programme data. It is obfuscation, not access control.
This is consistent with the rest of this repository’s findings on the Appmiral
platform shipping shared secrets client-side (see
hardcoded-credentials-and-keys.md).
2. Where the data lives
| Property | Value |
|---|---|
| API base | https://app.appmiral.com/api/v7/ (resource api_url_v6 in strings.xml) |
| Route shape | events/{festival}/editions/{edition}/{module} |
| Festival / edition | ttfestival / ttfestival2026 |
| Modules used here | stages, performances, artists |
| Response envelope | { "data": [ … ] } |
| Times | stored in UTC; rendered in Europe/Amsterdam (CEST) |
| On-device cache | SQLite db_ttfestival2026_nl.sqlite (tables Stage, Performance, Artist) |
Full read URLs:
https://app.appmiral.com/api/v7/events/ttfestival/editions/ttfestival2026/stages
https://app.appmiral.com/api/v7/events/ttfestival/editions/ttfestival2026/performances
https://app.appmiral.com/api/v7/events/ttfestival/editions/ttfestival2026/artists
3. The auth gate
The read API recognises several headers. Observed behaviour:
| Request | Result |
|---|---|
| No auth headers | 403 Forbidden — {"message":"Missing authentication"} |
X-Api-Key: <anything> |
401 Unauthorized — Invalid API key (proves a key scheme exists) |
x-protect: <app value> + app identity headers |
200 OK with the full JSON payload |
Default python-urllib User-Agent |
403 (blocked by Cloudflare before the app check) |
User-Agent: okhttp/4.12.0 (or curl’s default) |
passes Cloudflare |
The exact header set the app sends (and that we replay):
x-protect: 64d2…bb23 # redacted; from strings.xml -> x_Protect
x-app-version: v2.0.1
x-platform: android
x-app-id: com.appmiral.ttfestival
Accept: application/json
User-Agent: okhttp/4.12.0
x-protectis the only value that actually unlocks the data. Source: decompiledres/values/strings.xml, keyx_Protect(see §6). Redacted here as64d2…bb23.x-app-version/x-platform/x-app-ididentify the client; the API is lenient about these but the app always sends them, so we mirror them.User-Agentmatters only because Cloudflare sits in front of the API and rejects unknown/script UAs with a403that looks identical to the app-level auth failure. Use the app’s OkHttp UA (or curl) to get past it.
CORS: responses carry
Access-Control-Allow-Origin: *, so a browser on any origin can call the API directly — no proxy required. We still publish a committed JSON snapshot (see §7) so the page works offline and doesn’t depend on the upstream API staying reachable.
4. Techniques used
Three techniques, in the order they were applied.
4.1 APK decompilation (to recover the header contract)
How we learned the header names and the x-protect value without runtime
tooling.
- Prerequisites: the APK splits pulled from a device;
jadx1.5.1; JDK 17. - Steps: decompile the bundle and read the string resources — full procedure
in
reverse-engineering.md. The relevant value is atres/values/strings.xml, keyx_Protect(≈ line 1303). The API base is the adjacentapi_url_v6(≈ line 186). - Output: the static
x-protectvalue + confirmation the app talks toapp.appmiral.com/api/v7.
4.2 MITM TLS interception (to confirm the live request shape)
How we confirmed, at runtime, exactly which headers the app attaches to each request and that the server accepts them.
- Prerequisites: an owner-consented, rooted Android device;
mitmproxy/mitmdump;tcpdump. No APK repackaging (so there is no “you modified the binary” objection). - Steps (summary): inject the mitmproxy CA into the Android system (APEX
conscrypt) trust store at runtime, route device traffic through
mitmdump, and capture in parallel withtcpdump. The app has no certificate pinning, so request URLs, headers and bodies are fully readable. The full method (used for the location/identity capture) is documented inidentity-correlation-proof.md§8. - Output: ground-truth confirmation of the
x-*header set the app sends to the timetable endpoints.
4.3 Header replay (“spoofing”)
How we reproduce access with no device and no app — just the recovered header contract.
- Prerequisites: the
x-protectvalue (from §4.1 or §4.2);curlor Python; network access. - Idea: the API trusts any caller that presents the static
x-protectvalue plus the app-identity headers. Because that value is the same for every install and never rotates per session, a plain HTTP client can impersonate the app indefinitely. This is the technique the build script and the hourly GitHub Action use (§7).
5. Steps to reproduce
Supply the secret via an environment variable; do not hard-code it.
export X_PROTECT=<the x_Protect value>(PowerShell:$env:X_PROTECT="…").
5.1 curl (one endpoint)
curl -s \
-H "x-protect: $X_PROTECT" \
-H "x-app-version: v2.0.1" \
-H "x-platform: android" \
-H "x-app-id: com.appmiral.ttfestival" \
"https://app.appmiral.com/api/v7/events/ttfestival/editions/ttfestival2026/performances"
(curl’s default User-Agent already passes Cloudflare.) Drop the x-protect
header and the same call returns 403 Missing authentication.
5.2 Python (build the full dataset)
build_timetable.py fetches stages + performances + artists, joins them,
drops placeholder rows with no start_time, and writes timetable.json:
export X_PROTECT=<the x_Protect value>
python build_timetable.py
# wrote timetable.json: 164 events, 19 stages, fetched 2026-06-26T…Z
The script sets User-Agent: okhttp/4.12.0 explicitly (because Python’s default
UA is Cloudflare-blocked) and reads X_PROTECT from the environment, exiting if
it is missing.
5.3 Browser (CORS is open)
Because Access-Control-Allow-Origin: *, this works from any page’s console:
fetch("https://app.appmiral.com/api/v7/events/ttfestival/editions/ttfestival2026/performances", {
headers: {
"x-protect": "…",
"x-app-version": "v2.0.1",
"x-platform": "android",
"x-app-id": "com.appmiral.ttfestival",
},
}).then(r => r.json()).then(d => console.log(d.data.length));
6. Data model & quirks
Performance (schedule row): id, name (often null — fall back to the
artist name), artist_id, stage_id, stage_name, start_time, end_time,
hide_start_time, hide_end_time, sold_out, color, show_in_schedule,
priority.
Artist: id, name, body (= bio), image.
Stage: id, name, priority, color.
Quirks observed:
- ~45 placeholder rows in
performancesare fully null (no name/artist/stage/ time). They are filtered out by skipping rows withoutstart_time. - Always-on attractions (e.g. TT Kermis, Reuzenrad, Action on Wheels) carry nulled times in the live data — they are not time-slotted performances, so they drop out of the schedule grid by the same filter.
- Some bios contain mojibake (e.g.
fresia�s,New Caf� Image Stage). This is an encoding artefact in the source data and is left unmodified.
7. What we built on top of it
A standalone, auto-refreshing visualiser, published from a separate public repo so it stays independent of this evidence repo.
| Item | Value |
|---|---|
| Live page | https://graafg.github.io/tt-timetable/ |
| Repo | GraafG/tt-timetable (public) |
| Data builder | build_timetable.py (the script in §5.2) |
| Committed data | timetable.json (regenerated hourly) |
| Refresh | GitHub Action refresh.yml, hourly cron + workflow_dispatch |
| Secret | x-protect stored as Actions secret APPMIRAL_X_PROTECT, injected as X_PROTECT at runtime — never committed |
| Hosting | GitHub Pages (root of main) |
Page behaviour:
- Single static HTML that
fetchestimetable.json(cache-busted). No framework, no build step. - Opens on today and auto-scrolls to the current hour, drawing a red “now” line and outlining live performances. Late-night sets (before 06:00) group with the festival day they belong to.
- Grid (clash-finder) + list views, per-day tabs, artist/stage filter, detail popups. Times pinned to Europe/Amsterdam regardless of the viewer’s timezone.
The hourly Action rebuilds timetable.json and commits only when it changes,
so the page reflects the live programme without manual intervention.
An earlier, embedded-snapshot version of the visualiser also lives in this repo at
tools/timetable/— see its README. It was built before live API access was achieved and renders a frozen 2026-06-08 cache.
8. Snapshot vs live — proof the feed is genuinely live
Comparing the on-device cache snapshot (2026-06-08, 87 schedulable
performances) with a live pull (2026-06-26, 164) using compare.py:
| Metric | Snapshot | Live |
|---|---|---|
| Schedulable performances | 87 | 164 |
| Added (live, not in snapshot) | — | +96 |
| Removed (snapshot, gone from live) | — | −19 |
The 19 “removed” rows are not cancellations in most cases:
- 15 are TT Kermis (×9) and Reuzenrad (×6) — reclassified as always-on attractions with nulled times (§6), so they leave the time grid.
- 3 are Bomberjacks slots that were rescheduled (25 Jun → 26 Jun under new ids, hence they appear as one removal + one addition).
- 1 is Dirty Boulevard (28 Jun) — a genuine drop from the schedule.
The +96 additions are mostly newly-published acts and DJ sets (e.g. RADIONL daily slots, Hans Stroeve, DJ Joep Verhaar, The Dirty Daddies, Perfect Showband, 2 Brothers on the 4th Floor). This confirms the API serves an evolving, live programme — not a static export — which is why the published page refreshes hourly.
This diff is a point-in-time illustration. The live page now tracks the upstream programme automatically, so the exact numbers move over time.
9. Security assessment
| Aspect | Assessment |
|---|---|
x-protect as access control |
Ineffective. It is a single static string, identical for every install, never rotated per session, and trivially extractable from the APK or a no-pinning MITM capture. |
| Certificate pinning | Absent — traffic is fully interceptable on a device that trusts a user CA. |
| CORS | * — any web origin can read the API directly. |
| Cloudflare UA filter | Cosmetic — bypassed by sending the app’s (public) OkHttp UA or curl’s default. |
| Data exposed | The public event programme only. No personal data is behind this header. |
Takeaway: the programme is effectively public data behind a shared,
hardcoded obfuscation token. Treating x-protect as a credential (rotating it,
moving validation server-side per authenticated user) would be the only way to
make this gate meaningful — but for public programme data there is little reason
to gate it at all. The genuine privacy concerns in this app are elsewhere (see
crowd-connected.md and
identity-correlation-proof.md).
10. Caveats
x-protectis redacted throughout this repo; the live value ships in the APK and is supplied to automation via theAPPMIRAL_X_PROTECTActions secret.- The snapshot↔live diff is point-in-time (2026-06-08 vs 2026-06-26).
- All counts depend on the null-
start_timefilter; rawperformancesincludes placeholder and always-on rows that are intentionally excluded from the grid.