Mobile & screens
Instant messages (realtime)
A mini-app could ask, but never be told. Every capability before this number is the sandbox calling the host and waiting for an answer; there was no path for the host to call inward. So an app that wanted to know when its own server had said something had only one option: poll. A classroom screen asking "has anyone been called?" every two seconds, all day, about an event that happens twice a lesson. This channel reverses the direction — your app pushes news to its own surfaces.
Two halves, and only one bumped the contract — the difference is not a detail
Sending needed no contract bump. Publishing is an ordinary API call on an
already-allowed path: Okta.post('/api/apps/realtime/publish', …) works on
contract 21 with no new symbol, because Okta.post has sent since contract 1
and the partner surface is gated by scopes, not by symbols.
Receiving could not be expressed at all. No symbol in the injected library
could receive a handler, and a new symbol in an injected library is a
contract change: an app calling Okta.onMessage on a host at 21 does not fail
gracefully — it dies on the device with Cannot find static method Okta.onMessage, naming a file its author never wrote. Hence 21 → 22.
What the number means for you in practice: an app declaring
minContract: 22 is refused on an older host with an "update the app" screen —
which is the correct refusal, because the alternative is a compile death on a
television in a school. A host at 22 runs everything that came before exactly
as it did; nothing in contract 21 changed.
Declare
minContract: 22only if you receive. An app that publishes but never listens stays on its current floor and keeps working on every device in the field — and raising the floor without needing to does not make your app newer, it makes it refuse devices it used to run on.
Publishing — POST /api/apps/realtime/publish
Behind the realtime.messages.write scope. Declare it in scopes like any
other.
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': 'A-3'},
});
The body takes two keys and no others: event and data. A 202 answers:
{
"published": true,
"channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
"sent_at": "2026-09-06T07:39:46+00:00"
}
And this is what reaches your surfaces on the channel:
{
"event": "class.called",
"data": {"section": "A-3"},
"sent_at": "2026-09-06T07:39:46+00:00",
"from": "server"
}
Your event name never becomes the broadcast event name. The broadcast event
is the fixed string app.message for every message from every app, and
class.called travels inside the payload under event. Broadcasting under
your name would let an app broadcast device.command.queued — or any name a
platform surface binds on — and a client would act on a message a partner
wrote. One frame: the client binds once, and your vocabulary is data.
Refusals — and what to do about each
| Status | error |
What happened, and what you do |
|---|---|---|
403 |
scope_not_granted |
realtime.messages.write is not granted to this installation. Declare it and republish; the tenant grants it on update. |
422 |
invalid_event |
The event name does not match ^[a-z][a-z0-9_.:-]{0,63}$ — lowercase, starting with a letter, at most 64 characters. |
422 |
payload_too_large |
The data block exceeded 8192 bytes of JSON. Send a reference and let the surface fetch the rest over the API. |
429 |
rate_limited |
You exceeded 60 messages a minute for this installation. The body carries retry_after in seconds, and the response carries a Retry-After header too. |
The refusal text names the fix, not just the fault:
{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}
The two limits, and why they sit where they do
8 KB on data, so the channel does not become a second data path: anything
that needs data reads it from the API with its usual scope, which keeps scope
enforcement on one door. Send "section A-3 was called", not the section
roster.
60 messages a minute per installation — that is, per (school, app) pair. Not per app and not per school, because the pair is exactly the blast radius of a loop: a per-app limit would let a runaway loop in one school's copy silence your app in every other school, and a per-school limit would let one chatty app spend every other app's budget.
What is deliberately absent from the body
No tenant_id, no module. Tenant and module are read from the
server-side installation context alone, and there is no place in the body to
write them. Such a field would be an assertion, and honouring it would let
an app installed in one school address another school's screens — the one thing
the whole installation model exists to prevent. Send them if you like; they
change nothing.
And no Idempotency-Key. That header replays a stored response for 24
hours so a write does not happen twice, and nothing is written here. An
instant message served from a cache is a message that did not arrive while
the caller is told it did — worse than a visible error.
A publish whose broadcast fails still returns 202 and logs a warning. The caller already did the real work — recorded the attendance, called the class — and the message only announces it; failing the request would undo something that happened for the sake of an announcement that did not.
Receiving — Okta.onMessage (contract 22)
Okta.onMessage((String event, Map data) {
if (event == 'class.called') {
setState(() { _section = '${data['section']}'; });
}
});
One handler; a second call replaces the first. There is no handler list and
no unsubscribe, and both absences are deliberate:
- A handler list asks a question the sandbox cannot answer: which of them owns this message? One app, one channel, one handler.
- Unsubscribing has nothing to do: the handler dies with the mounted mini-app, because the host opens the socket on mount and closes it on teardown. An unsubscribe symbol would add exactly one thing — a way for you to get the lifetime wrong and go silent while still on screen.
And there is no send symbol. Okta.post has sent since contract 1, and a
second symbol for the same act is a second write path that drifts.
The socket belongs to the mounted app alone. Each target gets its own delegate, and each delegate its own socket and channel — so there is no shared socket for anything to leak through, and no app hears another's messages.
The channel name comes from the launch, not from the bundle. The bundle is
reused for as long as payload_version holds, so a channel carried on it would
be the channel of whichever tenant opened it before the school was switched — a
socket that authenticates cleanly and delivers another school's messages. The
launch is re-asked on every open, and the host takes the channel from there.
You never touch any of this: you call onMessage and nothing else.
Listening needs no scope — the installation is the permission. A scope is
what a school grants an app so it can reach the school's data; this channel
carries only what your app itself sent to its own surfaces, in the school that
installed it. What must hold is that the installation is live and active,
and that is re-checked on every subscribe: uninstalling or suspending closes the
channel. That is why there is no realtime.messages.read and never will be
— a grant that is requested and buys nothing is a grant partners think they
need.
The rule that saves your app: never wait for a message in order to draw
A dead socket changes nothing on screen. The realtime block on the launch
payload is optional, and its absence is a normal state, not a fault: no
broadcaster deployed, a server older than the feature, a platform where the
socket does not open. Every failure after that is swallowed and logged:
refused auth, dead host, malformed frame, a handler that throws.
And from inside the sandbox, a host that never calls your handler and a silent channel are the same thing — you have no way to tell them apart. So there is one rule:
Read state with
Okta.get, then let messages update it. An app that draws an empty screen and waits for its first message shows that emptiness forever on any device outside coverage — which is exactly what a television in a school with a flaky network looks like.
A socket that never connects costs you freshness and nothing else. And the host retries for you: backoff starting at 2 seconds, doubling to a ceiling of 5 minutes, with ±20% jitter, and the counter resets on a successful subscribe — not on a successful connect.
Do not throw inside the handler. It is interpreted code invoked from a
socket callback — outside any build, so there is no error boundary beneath
it. The host wraps it twice as a backstop, but keep the body defensive: read
from data carefully, and assume no key.
And as the publisher: emit every field,
nullwhen empty. A key merely missing from the decoded map is a rawnullin dart_eval —isthrows on it, and evenOktaJson.strthrows. Keep your payload a fixed shape and fill the gaps with an explicitnull. You are both ends here, so the contract is between you and yourself.
A worked example: "class roll call"
One app, two surfaces: a teacher calls a section from their phone, and the classroom screen updates with no polling.
The phone surface — publishes after doing the real work:
Future<void> callSection(String section) async {
// 1) The real work first: it is recorded on your own server.
final res = await Okta.post('/api/roll-call/calls', {'section': section});
if (res.status < 200 || res.status >= 300) {
Okta.toast('Could not record the call');
return;
}
// 2) Then the announcement. Its failure does not undo (1).
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': section, 'called_at': '${DateTime.now()}'},
});
Okta.playSound('success');
}
The screen surface — draws from state, then messages update it:
class Board extends StatefulWidget {
const Board({super.key});
@override
State<Board> createState() => _BoardState();
}
class _BoardState extends State<Board> {
String _section = '';
@override
void initState() {
super.initState();
// First: draw what is true now. This is the screen on a device
// whose socket never connected — and it is a correct screen.
_loadCurrent();
// Then: let the message update it. Nothing here gates the draw.
Okta.onMessage((String event, Map data) {
if (event != 'class.called') return;
final dynamic v = data['section'];
setState(() { _section = '$v'; });
});
}
Future<void> _loadCurrent() async {
final res = await Okta.get('/api/roll-call/calls/current');
if (res.status != 200) return;
final dynamic v = res.json()['section'];
setState(() { _section = '$v'; });
}
@override
Widget build(BuildContext context) {
return Center(
child: Text(
_section.isEmpty ? 'No call right now' : 'Called: $_section',
),
);
}
}
And in your manifest — on the screen package alone, if that is the receiver:
"mobile": {
"minContract": 22,
"screen": {
"enabled": true,
"title": "Class roll call",
"places": [
{ "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
]
}
}
Note what is not in the example: no polling loop, no
Timer, noFuture.delayedre-asking the question. And if the socket drops, the screen keeps whatever_loadCurrent()last read — stale, not blank.
The realtime block on the launch payload
The launch payload carries a realtime block (on the screen launch too) with
the driver, key, host, port, channel name and auth endpoint. You do not
use it — the host consumes it and opens the socket; it is mentioned here only
because it explains why the channel comes from the launch and not from the
bundle.
When the broadcaster is not configured the block arrives as null, not as a
missing key: absence would force a client to conflate "server older than the
feature" with "feature switched off" — two facts with two behaviours.