Living reference

Pulse mobile sync

A companion app pre-caches an event or series over wifi — agenda, speakers, branding, and the whole slide deck — so the venue's network only carries live signals. Content is encrypted at rest on the device; the deck stays unreadable until the Pulse server releases each slide's key as the presenter shows it. The iOS/Android app is a separate project; this is the frozen server contract it builds against.

Every endpoint below is stable. The app is just another client of these routes — no server changes are needed to ship it.

1. Enroll the device

The app generates a P-256 keypair in the device's secure element (Secure Enclave / hardware Keystore — non-exportable) and enrolls the public half with a one-time credential: a staff-minted device code (Console → Connect AI → Companion app devices) or a series registration token (a conference attendee's link). The server returns a device id and a signed device certificate.

POST /api/sync/enroll
{ "token": "<device code or registration token>",
  "publicKey": "<P-256 SPKI, base64>", "platform": "ios", "name": "Ada's iPhone" }
→ { "deviceId": "…", "deviceCert": "<base64url(json).base64url(sig)>" }

Unauthenticated (the token is the secret) and rate-limited. A bad public key → 400 badKey; a bad/used token → flat 404. Device codes are single-use; registration tokens are durable.

2. Sign every request (certificate authentication)

Each device-scoped request carries an X-Pulse-Device header: the device id, a Unix timestamp, and an ES256 signature made with the secure-element key over a canonical string. The server verifies it against the registered public key.

X-Pulse-Device: {deviceId};ts={unixSeconds};sig={base64url}

canonical = METHOD + "\n" + PATH + "\n" + QUERY + "\n" + ts + "\n" + SHA256(body, hex)
sig       = ES256( canonical )         // IEEE-P1363 (raw r‖s), base64url
ConditionResponse
Unknown or revoked device, or a bad signature404 (flat — no hint)
Timestamp outside ±300 s of the server clock401 staleSignature
Too many manifest/keys calls (~5/s per device)429

3. Pre-cache the package

EndpointReturns
GET /api/sync/manifest?session={id}Content only — session basics, agenda, speakers, branding, visible resources (timeline-gated ones as metadata), survey, and the encrypted deck: slides: [{ n, assetHash, bytes }]. packageVersion is the ETag; send If-None-Match for a 304.
GET /api/sync/series/{id}/manifestDay-grouped items/rooms + a { id, title, packageVersion } ref per linked session to pre-cache.
GET /api/sync/asset/{hash}One slide's ciphertext (AES-256-GCM), content-addressed and immutable-cacheable. Store it exactly as served — never decrypt to disk.
GET /api/sync/appconfig?org={slug}Public. White-label branding (product name, colors, theme, logo) so the app skins itself before enrollment.

Delta sync is a conditional manifest GET: unchanged → 304; changed → fetch only the new hashes and drop the ones no longer referenced. Because ciphertext is content-addressed, a shared cache or CDN can hold it without being trusted.

4. How timing works (the key is server-gated)

Caching a slide early does not grant early access. Every slide ships encrypted, and the app never holds the package secret. Decryption keys come only from:

GET /api/sync/keys?session={id}
→ { "deckVersion": 3, "released": [ { "n": 0, "keyB64": "…", "nonceB64": "…" }, … ] }
Event stateKeys released
Draft / scheduled / rehearsalnone — the deck is ciphertext only
Liveslides 0 … MaxShown (the presenter's frontier; venue-locked decks also require being in the room)
Endedthe whole deck only if the organizer allowed an unconditional download; otherwise it stays frontier-scoped
There are no release timestamps anywhere in the package. A copied cache, a modified app, or a device with a wrong clock decrypts nothing early, because the key material simply isn't on the device until the server releases it. GET /api/sync/time exists for display only — it is never used to decide access. The app follows the live realtime channel and re-fetches keys on each advance.

5. Engage & report

In the room, the app joins as an attendee over the existing realtime channel (reactions, polls, Q&A, chat) and posts the same connection sample as the web client to POST /api/sessions/{id}/diag — so app clients appear in the moderator's live view and the end-of-event report, with build = the app version.

See also the FAQ and the docs index.