/v1/mobile and nothing else. Every endpoint is a GET,
and it answers only to a device key. That key opens nothing outside
/v1/mobile, and no other key opens /v1/mobile — not even the process
key.
Device keys
Each device gets its own named key: one per phone, one per watch. The service never holds a key. It is configured with the key’s SHA-256 hash:- Names must match
mobile-[a-z0-9-]+. Use a pseudonymous handle, not a person’s name. The name is what the console and the event log show. - Every device has its own key. Two entries with the same hash are refused, and so is a hash of any other configured secret, including one set from the console. The console in turn refuses to set a secret to a device key.
- Device keys count as configured credentials. With only device keys set,
every other route answers 401, and
ALLOW_UNAUTHENTICATEDhas no effect. - A malformed value stops startup, like the other key lists. Check the
complete value with
scripts/validate_mobile_key_hashes.pybefore setting it (below). - Adding or revoking one device needs nobody else’s key, because only hashes are stored.
- The console’s configuration view masks the value. Status → security → mobile_callers lists every configured device with its last request, request count, app version and platform. That is kept in memory only, and no IP address is ever recorded.
- The first request of each device after a start logs
mobile_key_first_use. Reads are otherwise not logged, and nomobile_*event reaches the Slack channel shared with Cloover.
Endpoints
More endpoints are added as the app grows; the
contract lists what exists.
The event feed
The feed shows every cataloged warning and error, plus the routine steps an operator follows through the day: forecast received, backup created, CSV published, schedules submitted, service started. It never shows the access log, the mail log, the classifier’s second opinions or the app’s ownmobile_* events.
No message, no data. An event’s message and data can carry MaLo IDs,
e-mail addresses, client addresses and partners’ free text. None of it
reaches the device. The words (text_de, text_en) are built on the
server from the event’s type, its delivery day and typed numbers and flags
only, for example “30.07.2026: Nicht eingereicht vor Gate-Schluss, noch
25 Min.”. As a second line of defence they are redacted once more for
e-mail addresses, IDs, IBANs, phone numbers, URLs and IP addresses.
Paging never skips or repeats an event:
- Pages are oldest first.
limitis 1–200 (default 50). - Both cursors at once are an
invalid_request. - A page reads a bounded number of events, so with many events the feed
leaves out, a page can come back short or empty while
has_moreis true. The cursors still move past everything read: keep paging. - An app polls with
after_idset to the last page’snext_after_id. epochis the first event’s timestamp, or when the log was found truncated, restored or replaced. Ids never repeat; when the epoch changes, start again from the newest page, because the events before the cursor are no longer the ones the app saw.
events_24h in the summary and the snapshot counts the warnings and
errors of the last 24 hours that the feed would show.
Forecasts
?von&bis names at most 14 days (default: today to the end of the forecast
horizon). Every day of the range is listed, a day without a forecast with
no versions. Per day: the active quality, the three steps, the confirmed
customers in supply per control area, and each stored version (customer,
generated, backup) with its energy per control area. A backup also carries
its derivation (copied_from, method, customers_without_reference),
and its deviation from the customer’s forecast once both exist.
series=true works for a single day only. It adds each version’s
quarter-hour totals in MW and interval_starts_local, the label of every
quarter-hour. On the autumn clock change the repeated hour reads
02:00 A … 02:45 A, 02:00 B … 02:45 B, the way EPEX marks it; in
spring 02:00–02:45 does not exist. A forecast’s MaLo mapping never leaves
the service, and there is no suppression of small portfolios.
Market partners
The partner overview reduced to counts:totals counts every registry
partner once; areas[] counts each partner once in every control area it
has Bilanzierungsgebiete in. rejected[] lists every standing rejection,
newest first, one entry per stream:
The labels (
reason_label_de, reason_label_en) are fixed words, for
example “APERAK Z29: Absender nicht registriert”, with the fallbacks
“APERAK negativ”, “CONTRL negativ” and “Übertragungsfehler (AS4)”. The
partner’s own text never reaches the app: not an APERAK’s FTX, not a
transport error, not an operator’s note. Partner names and MP-IDs are
public BDEW registry data. The app shows them on this screen, never in a
widget, on the watch or on the lock screen. When MakoFlow cannot be read,
the answer is 503 upstream_unavailable, not old data.
Kundenhochlauf
Per Bilanzierungsgebiet with confirmed customers: the Netzbetreiber (its registry name, never the name in Cloover’s sheet), its market communication status, the Zeitreihentypen not active in time (fehlend),
ohne_zoe and customers without a forecast type (ohne_typ). An area is
urgent when something is missing and its first supply start is at most
14 days away, or past. Urgent areas come first.
kein_quellstand: Cloover has sent no snapshot yet.unvollstaendig: the comparison with the market could not run (MakoFlow unreachable). The areas are then missing, not fine. Why it failed is not passed on.- In either case the app says so; it never says “nothing urgent”.
standis the sheet’s own date,YYYY-MM-DD HH:MM; the rest of that note stays on the server.- Reading it records nothing: unlike the console’s view, the app’s does not store newly seen activations.
The day-ahead badge
One colour, code and label says how the day ahead stands. The server decides it with one function; the apps render what they are given and hold no rules or label tables of their own. Labels come in German and English (label_de, label_en). The first matching rule wins:
- A stopped service turns a neutral or green badge into amber
stopped(“gestoppt”); amber and red keep their own. - Every badge carries
next_gate_atonce today’s gate has passed. - Closure and warning come from the live configuration
(
GATE_CLOSURE_LOCAL_TIME,GATE_CLOSURE_WARN_MINUTES). 05:00, 09:00 and 12:00 are fixed. t_plus_1.published_atis when the current input’s CSV first went out; sending the same file again does not move it. Days published before it was recorded count as in time.last_orchestrate_atcomes fromservice_heartbeat.json, which every orchestrator call writes, also while stopped.heartbeat_staleis set after 20 minutes without a call (MOBILE_HEARTBEAT_STALE_AFTER_SECONDS).- The summary is shared between devices for at most 15 seconds, and never across an event: a configuration change, a stop or start, a submission, a publish or a new forecast makes the next request read afresh.
badge_timeline says what the badge will be at future instants if
nothing changes and nothing is fetched. Widgets and complications use it to
change colour offline. It is built with the same function, at the
instants where a rule can change: now, today’s 05:00, 09:00, warning start
and closure, the next midnight (from which the next day ahead is read),
and the same four instants tomorrow. It ends at timeline_end, the
midnight after that.
Staleness is the one rule the apps apply themselves, with the server’s
thresholds. From generated_at + stale_after_s (30 minutes) they keep the
timeline’s colour and add “Stand HH:MM”. From generated_at + expire_after_s (6 hours) or timeline_end, whichever comes first, they
show grey. Grey never comes from the server.
snapshot is exactly what native surfaces may store: the badge, its
timeline, overlays, the day ahead’s steps and gate, and counts (including
events_24h). It holds no energy, names, event text or caller. Widgets, the watch and the Live
Activity store it verbatim, so the contract covers them too.
Time. Every instant is RFC 3339 with seconds and an explicit offset;
event timestamps are UTC (Z). Every Berlin wall-clock value a person
reads also comes as a *_local "HH:MM" string, so the app needs no
time-zone data. Today is today in Berlin. The day ahead has 92, 96 or
100 quarter-hours.
Errors are exactly {"error": "<code>"}, with no detail text. Every
response carries Cache-Control: no-store, errors included.
The contract
mobile/contract/ in the repository is the contract between the service
and the apps:
openapi.json— only the/v1/mobilepaths and their schemas, generated bypython scripts/generate_openapi.pyfrom the same response models the service uses. Every model forbids extra fields, so the service returns exactly what is declared.fixtures/<endpoint>/<scenario>.json— canonical responses on a fixed clock: for the summary, one per badge code plus a stale heartbeat, a backup replaced by the forecast and both clock changes; for the event feed, the newest page, paging back and forward, caught up, and errors only; for forecasts, a range and two days with series (one of them the autumn clock change); for market partners, rejections of every kind and no traffic; for the Kundenhochlauf, urgent areas, no snapshot and an incomplete comparison. They are regenerated withUPDATE_MOBILE_FIXTURES=1 pytest tests/test_mobile_contract.py.
oasdiff breaking against the base branch’s spec.
Within v1, changes are additive only: new fields and new enum
values. Clients ignore fields they do not know and map unknown enum values
to “unknown”. A removed or retyped field goes to /v2/mobile. Remove a v1
field only after mobile_callers has shown no app version that still
reads it for 30 days.
Runbook: adding, rotating or revoking a device key
The operator runs these steps; nothing here runs in CI or from Claude. Never paste a key into a chat, a ticket or a terminal that is being shared. Restarts avoid 05:00 and 13:30–14:30 Berlin time.1
Generate the key and its hash
On your own machine, without echoing the key:
token_urlsafe never produces , or :, which the list uses as
separators.2
Build and check the complete value
The value is the whole list. Take the current list from the password
manager, add It prints the device names, or why the service would refuse to start.
mobile-<handle>-<device>:$HASH (or remove the revoked
entry), then check it:3
Store it in Key Vault
az containerapp secret set -n "$APP" -g "$RG" --secrets mobile-api-key-hashes="$VALUE".4
First time only: reference it from the app
The container app needs an identity that may read the vault
(Key Vault Secrets User on the vault). Then reference the secret and
map it to the variable:The
update creates a new revision and restarts production.5
Later changes: restart the running revision
A new secret value alone changes nothing in the running revision.
After adding a device, rotating or revoking a key, restart the latest
revision:Then check Status → security → mobile_callers in the console: the
list is the new one.
6
Hand the key to the device
Through the password manager, which fills the app’s key field without
the clipboard. A QR code is fine if it is generated offline
(
printf %s "$KEY" | qrencode -t ANSIUTF8): never with a web
generator, never during a screen share. Clear the terminal and
unset KEY afterwards.deploy.yml nor the bicep template touches these settings. Never
re-run the full template against production: it drops the secrets and
environment variables set this way.