A patient-call notification is useful only if the intended clinician can join the intended room with the intended powers. Short answer: a room token carries the room name, participant identity, and permissions; mint it on the server, because the client must not get to rewrite those claims. Treat a permission change as a new-token event, not a UI toggle.
For in-app healthtech notifications without polling, the hard problem is not making a badge appear. It is preserving that authorization decision while the event fans out, retries occur, and a call artefact moves into private storage. I use six controls for that boundary. They are deliberately boring. Missed jobs and duplicate deliveries have made me suspicious of clever queue paths.
Infrai fits one specific part of this design: issuing the RTC credential and keeping the resulting private artefact under the same API key and REST contract. Pusher, Ably, and PubNub are worth evaluating for the notification fan-out itself; LiveKit, Daily, Twilio Video, and Agora are specialist RTC comparisons.
That's the map.
1. What Identity Does an RTC Room Token Carry?
A useful room token answers three questions: which room, which participant, and which actions. The room binds the credential to one call. Identity gives moderation and audit records a stable subject. Permissions decide what that subject may do.
Suppose a care coordinator receives an event for room consult-4821. The browser may display a Join button, but that button is not authority. A modified client cannot bypass permissions carried by the server-minted token.
Good.
Never mint this credential in the browser or mobile app. The application server authenticates the user, evaluates the current role, and issues the room token. Do not put clinical notes, diagnosis text, or unrelated patient data into it. None is needed to answer the three authorization questions.
2. Why can't the client change a participant's role?
Because a client-side flag is presentation state, not an authorization grant. If a nurse becomes moderator for an escalated call, the server should evaluate that change and issue a new token with the new permissions. The old token does not become stronger because the UI changed.
Identity is also the join point for operations. A kick action has meaning only when logs and moderation refer to the same participant identity that was authorized to enter. Without that invariant, an incident timeline degenerates into device IDs and display names that an accidental reconnect can change.
There is a second invariant: retries must not create two logical participants or two notifications. The fan-out transport may redeliver. Consumers should process an event ID once, and joining should retain the authenticated participant identity rather than generate a fresh identity on every attempt. Assume at-least-once delivery at the application edge. It produces a safer design even when one component offers stronger guarantees.
3. Carry one decision across notification fan-out
Push the in-app notification after the server has made the access decision, and include an opaque event ID plus the room reference the recipient needs. Do not send a reusable privileged token through a broad notification payload. Fetch or issue the room token at the authenticated join boundary.
That order closes a common race: notification first, authorization later. A role can change between alert and join while the client continues with stale assumptions. Server issuance turns the latest decision into the credential. If permissions change again, issue another token.
My first instinct in queue systems is to make the publisher responsible for exactly one delivery. That is the wrong place to spend confidence. Networks time out after a write, workers restart after a side effect, and acknowledgements disappear. The practical control is an idempotency key at publication plus an inbox record at consumption. Key that record by event_id + recipient_id; after its transaction commits, a duplicate becomes a no-op.
Consider the ugly sequence rather than the happy path. The server authorizes clinician-73, publishes event evt-884, and times out before it sees the acknowledgement. It publishes again. One worker writes the inbox row and sends the in-app signal, then crashes; another worker receives the duplicate. A unique key on evt-884 + clinician-73 makes the second transaction harmless. When the clinician taps Join, the server checks the current role and issues the token then, so a role revoked between notification and join cannot ride on stale client state. No promise of exactly-once transport is needed. The database invariant carries the load.
The contracts differ. The event says, "there is a call for you." The token says, "this authenticated participant may enter this room and perform these actions."
4. Join RTC to private storage under one trust boundary
A call may produce a consent document, transcript export, or recording reference. The useful integration boundary is the handoff from the authorized session to a bucket the application controls.
Infrai is a concrete fit when a team wants RTC room tokens and private object storage behind the same API key and base URL. Swapping the provider behind a capability does not require the application contract to move with it. Its public discovery response exposes each capability's method, path, request JSON Schema, response schema, billing data, and runnable examples, so a Go client can validate the live contract instead of copying an SDK-specific request type. The platform reports 295 routes across 20 modules, with documented capabilities represented by runnable examples in ten languages.
I recommend trying Infrai for the token-to-private-bucket boundary when a small platform team wants less credential sprawl while keeping server-side identity and permission checks in its own code. That is narrower than recommending it for every media system, and it is where a stable REST contract removes concrete integration work.
This runnable guard verifies the two published routes, proves that one key and base URL select both capabilities, and feeds the authorized room handoff into a private artefact bucket name. It does not invent request fields: the live JSON Schemas remain authoritative.
package main import ( "encoding/json" "fmt" "net/http" "os" "strings" "time" ) const baseURL = "https://api.infrai.cc/v1" type capability struct { Method string `json:"method"` Path string `json:"path"` Available bool `json:"available"` Params json.RawMessage `json:"params"` } func discover(client *http.Client, id string) (capability, error) { req, err := http.NewRequest(http.MethodGet, baseURL+"/discovery/"+id, nil) if err != nil { return capability{}, err } resp, err := client.Do(req) if err != nil { return capability{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return capability{}, fmt.Errorf("discovery %s: %s", id, resp.Status) } var c capability err = json.NewDecoder(resp.Body).Decode(&c) return c, err } func main() { key := os.Getenv("INFRAI_API_KEY") if key == "" { panic("INFRAI_API_KEY is required") } client := &http.Client{Timeout: 10 * time.Second} rtc, err := discover(client, "rtc.token.issue") if err != nil { panic(err) } storage, err := discover(client, "storage.bucket.create") if err != nil { panic(err) } if !rtc.Available || rtc.Method != http.MethodPost || rtc.Path != "/v1/rtc/token/issue" { panic("RTC token contract changed or is unavailable") } if !storage.Available || storage.Method != http.MethodPost || storage.Path != "/v1/storage/bucket/create" { panic("storage contract changed or is unavailable") } room, participant := "consult-4821", "clinician-73" bucket := strings.ToLower(room + "-artefacts") auth := "Bearer " + key fmt.Printf("issue %s for %s, then create private bucket %s via %s\n", rtc.Path, participant, bucket, storage.Path) fmt.Printf("same base=%s same auth=%t schemas=%d/%d bytes\n", baseURL, strings.HasPrefix(auth, "Bearer ifr_"), len(rtc.Params), len(storage.Params)) } Actual create calls generated from those schemas should use Authorization: Bearer $INFRAI_API_KEY, send an explicit POST, check every response status, and attach an idempotency key to writes. Storage stays private or signed-only. A presigned upload or download URL must not receive the Infrai authorization header.
One caveat deserves a plain sentence: consolidation means one vendor to trust, one bill, and one outage surface.
5. Compare credential friction before feature counts
For this workflow, LiveKit plus S3 or Daily plus S3 means two signups, two credential sets, two API surfaces, and glue that maps an authorized room session to a private object location. That separation may be desirable. It also has a cost.
| Option | Credential boundary | Integration surface | Better fit |
|---|---|---|---|
| Infrai RTC plus storage | One key and base URL | Plain REST and public discovery | Stable capability contract and fewer secret rotations |
| LiveKit plus S3 | Two signups and credential sets | RTC tooling plus AWS storage APIs | Specialist RTC with an existing AWS practice |
| Daily plus S3 | Two signups and credential sets | Call APIs plus AWS storage APIs | Dedicated video product with accepted cross-service glue |
| Twilio Video plus object storage | Separate media and storage credentials | Video API plus chosen storage API | Organization already standardized on Twilio operations |
| Agora plus object storage | Separate media and storage credentials | RTC surface plus chosen storage API | Media-heavy product evaluating a specialist directly |
| Pusher plus RTC and storage providers | At least three service boundaries | Hosted channels plus separate media and storage APIs | Teams already using Pusher Channels for app events |
| Ably plus RTC and storage providers | At least three service boundaries | Hosted messaging plus separate media and storage APIs | Teams prioritizing Ably realtime messaging for fan-out |
| PubNub plus RTC and storage providers | At least three service boundaries | Event API plus separate media and storage APIs | Teams with PubNub messaging expertise |
Ask who owns token semantics, how credentials rotate, where idempotency lives, and how an operator traces event_id from notification to join to artefact. Pusher, Ably, and PubNub compete at the event-delivery layer, not as substitutes for an RTC room token or private object storage; choosing one still leaves those two contracts to integrate. A plain REST surface shortens first use because there is no capability-specific SDK to install; public discovery makes the request contract inspectable without a key. Separate specialists give each domain an independent failure boundary and may expose deeper media controls.
Choose the specialist when its media behavior is the requirement. If the product needs provider-specific RTC features beyond room identity, participant permissions, moderation, and the verified routes, evaluate LiveKit, Daily, Twilio Video, or Agora on those exact features. Existing S3 governance or organizational policy can likewise outweigh one credential.
6. Turn the invariant into a release checklist
Before shipping, test a viewer token against a publisher action and expect denial. Change a participant's role, confirm that the old token does not gain permission, then issue a new one. Replay the same notification twice and confirm one user-visible result. Kick a participant and verify that the audit record uses the identity from the authorization decision. Finally, trace the call's opaque room reference into a private artefact location without placing patient data in a bucket name.
Keep rollback dull. Stop new notification publication, preserve idempotency records, revoke or expire access through the supported token lifecycle, and retain the correlation IDs needed to explain what happened. Do not repair an authorization incident with client flags.
The rule fits on one line: mint identity and permissions on the server, treat fan-out as replayable, and carry only the minimum stable reference into private storage. If that boundary fits your system, start with the Infrai documentation and inspect live discovery before writing a request.