Skip to the content.

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)

  1. 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.
  2. The read API is reachable from anywhere, but is gated by a single static header: x-protect. Without it the API returns 403 Missing authentication; with the app’s hardcoded value it returns 200 OK.
  3. That x-protect value 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).
  4. There is no certificate pinning and CORS is wide open (Access-Control-Allow-Origin: *), so the programme can be pulled with curl, a Python script, or even a browser fetch() from any origin.
  5. 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

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.

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.

4.3 Header replay (“spoofing”)

How we reproduce access with no device and no app — just the recovered header contract.


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:


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:

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:

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