Backend
Backend
How a backend service turns a domain event into an APNs push that this starter can render.
In plain English: your backend describes what is happening once, validates that shape, and sends the right projection to Apple Push Notification service (APNs). Mobile Surfaces ships the local pieces, fixtures, the contract package, the Node SDK, the harness, and APNs smoke scripts. A real production push service (queues, durable retries, observability) is intentionally out of scope; the goal of this doc is to make the integration shape obvious so you can build the production half against a stable contract.
For the full push surface (token taxonomy, channel management, error responses, retry policy, smoke-script flag combinations), see docs/push.md. This page is the high-level “how does it work end-to-end” piece; the push doc is the “how do I drive the wire layer” piece.
For a single runnable file that wires every piece below together (token forwarder ingress, domain event ingress, projection, APNs dispatch), see apps/example-backend/. It is a reference, not a deployable (in-memory storage, no auth, no deploy config); the wire-boundary parse pattern is the part that ports to production unchanged.
Mental Model
The contract is one type with kind-gated derived shapes.
flowchart LR
Event["Domain event<br/>(yours)"] --> Snapshot["LiveSurfaceSnapshot<br/>(@mobile-surfaces/surface-contracts)"]
Snapshot --> Content["ActivityKit ContentState<br/>toLiveActivityContentState()"]
Snapshot --> Alert["Alert payload<br/>toApnsAlertPayload()<br/>(@mobile-surfaces/push)"]
Snapshot --> Widget["Widget timeline entry<br/>toWidgetTimelineEntry()"]
Snapshot --> Control["Control value<br/>toControlValueProvider()"]
Snapshot --> Notification["Notification content<br/>toNotificationContentPayload()"]
Content --> SDK["@mobile-surfaces/push<br/>(Node SDK, recommended)"]
Content --> Script["scripts/send-apns.mjs<br/>(protocol reference / smoke)"]
SDK --> APNS["APNs"]
Script --> APNS
LiveSurfaceSnapshot is the only shape your domain code should emit. Everything else is a pure transform. The recommended wire-layer driver is the Node SDK at @mobile-surfaces/push; the reference implementation of the same wire shape is scripts/send-apns.mjs, which is intentionally self-contained so you can read the protocol top-to-bottom in one file. Both target the same APNs endpoints. See packages/surface-contracts/src/index.ts for the full type and projection helpers, and packages/push/src/client.ts for the SDK client.
Snapshot Fields
interface LiveSurfaceSnapshotBase {
schemaVersion: "5"; // discriminator; bumped only on breaking changes
kind: "liveActivity" | "widget" | "control" | "lockAccessory" | "standby" | "notification";
id: string; // unique per snapshot revision (event-scoped)
surfaceId: string; // stable across snapshots for the same surface
updatedAt: string; // RFC 3339 datetime; required for out-of-order discard
state: "queued" | "active" | "paused" | "attention" | "bad_timing" | "completed";
}
// Plus per-kind slices on the matching branch (every rendering field lives in
// the slice for the kind that actually uses it; v4 collapsed the base to
// identity + state only):
// kind: "liveActivity" -> liveActivity: { title, body, progress, deepLink, modeLabel, contextLabel, statusLine, actionLabel?, stage: "prompted" | "inProgress" | "completing", estimatedSeconds, morePartsCount }
// kind: "widget" -> widget: { title, body, progress, deepLink, family?: "systemSmall" | "systemMedium" | "systemLarge", reloadPolicy?: "manual" | "afterDate" }
// kind: "control" -> control: { label, deepLink, controlKind: "toggle" | "button" | "deepLink", state?: boolean, intent?: string }
// kind: "notification" -> notification: { title, body, deepLink, category?: string, threadId?: string }
// kind: "lockAccessory" -> lockAccessory: { title, deepLink, family: "accessoryCircular" | "accessoryRectangular" | "accessoryInline", gaugeValue?: number, shortText?: string }
// kind: "standby" -> standby: { title, body, progress, deepLink, presentation: "card" | "night", tint?: "default" | "monochrome" }
kind selects the projection path (the schema is a true z.discriminatedUnion("kind", …), invalid kind/slice combinations fail at parse time). state is the canonical state machine: drive the lifecycle from the backend. liveActivity.stage is a UI-facing axis (whether the surface is being prompted, actively running, or wrapping up); it only applies to liveActivity-kind snapshots. progress lives on whichever slice renders it (liveActivity, widget, standby).
updatedAt is a required base field. Set it to the wall-clock instant the snapshot was authored, ideally UTC for trivial lexicographic comparison. Consumers use it to discard out-of-order pushes that ActivityKit and APNs do not order in-band; see schema.md for the migration policy.
For a tour of every kind value and the projection it drives, see docs/surfaces.md. Look at data/surface-fixtures/*.json for committed examples of every state and kind.
End-to-End Walkthrough
The motivating use case: a backend job moves through queued → active → completed, and you want a Live Activity on the user’s Lock Screen plus a fallback alert push for anyone who has the activity disabled.
1. Map the domain event
Stay in your own service for this step. The output is a LiveSurfaceSnapshot. A minimal mapper:
import type { LiveSurfaceSnapshot } from "@mobile-surfaces/surface-contracts";
function snapshotFromJob(job: Job): LiveSurfaceSnapshot {
const state = job.status === "queued"
? "queued"
: job.status === "running"
? "active"
: job.status === "done"
? "completed"
: "attention";
return {
schemaVersion: "5",
kind: "liveActivity",
id: `${job.id}@${job.revision}`,
surfaceId: `job-${job.id}`,
updatedAt: new Date().toISOString(),
state,
liveActivity: {
title: job.title,
body: job.subtitle ?? "",
progress: clamp01(job.progress ?? 0),
deepLink: `mobilesurfaces://surface/job-${job.id}`,
modeLabel: state,
contextLabel: job.queueName,
statusLine: `${job.queueName} · ${state}`,
actionLabel: "Open job",
stage: state === "completed" ? "completing" : state === "queued" ? "prompted" : "inProgress",
estimatedSeconds: job.etaSeconds ?? 0,
morePartsCount: job.extraItems ?? 0,
},
};
}
Keep this function pure.
2. Validate at the wire boundary
The contract package ships two validators with different policies:
import {
assertSnapshot,
safeParseSnapshot,
safeParseAnyVersion,
} from "@mobile-surfaces/surface-contracts";
// Strict v5 only. Throws ZodError on anything that isn't a current-version
// snapshot. Use this on outbound code paths where you control the producer.
const snapshot = assertSnapshot(snapshotFromJob(job));
// Strict v5 safe-parse. Returns { success, data | error }.
const result = safeParseSnapshot(input);
// Wire-edge tolerant. Tries v5 first; falls back to v4 with auto-migration
// and emits a deprecationWarning on success. Use this on inbound code paths
// (HTTP handlers, queue consumers) where producers may not have migrated to
// v5 yet. The v4 codec lives through the 8.x release line; see
// docs/schema.md for the deprecation timeline.
const versioned = safeParseAnyVersion(input);
if (versioned.success) {
if (versioned.deprecationWarning) {
log.warn(versioned.deprecationWarning, { snapshotId: versioned.data.id });
}
// versioned.data is a LiveSurfaceSnapshot in v5 shape.
}
safeParseAnyVersion is the migration path documented in docs/schema.md. Use it whenever you read snapshots from a store that may still hold v4 payloads. The v4 -> v5 codec runs in safeParseAnyVersion; the v3 codec was retired at 8.0.0 and the v2 codec earlier at 5.0.0.
The published JSON Schema at unpkg.com/@mobile-surfaces/[email protected]/schema.json is generated from the same Zod source and pinned to major.minor. Use it for IDE tooling, OpenAPI components, or non-TypeScript validators (Ajv, jsonschema, etc.). Standard Schema interop is automatic, every exported Zod schema implements the ~standard getter ({ vendor: "zod", version: 1, validate, jsonSchema }), so the contract drops directly into Standard-Schema-aware libraries (Valibot runners, ArkType, @standard-schema/spec) without depending on Zod at runtime.
3. Send the APNs request
Use the Node SDK. One client per (auth-key, environment, bundleId) tuple, multiplexed across alert / Live Activity / broadcast / channel-management requests:
import { createPushClient } from "@mobile-surfaces/push";
import { toLiveActivityContentState } from "@mobile-surfaces/surface-contracts";
const push = createPushClient({
keyId: process.env.APNS_KEY_ID!,
teamId: process.env.APNS_TEAM_ID!,
keyPath: process.env.APNS_KEY_PATH!,
bundleId: process.env.APNS_BUNDLE_ID!,
environment: "development", // or "production"
});
// Live Activity update against an existing per-activity push token.
await push.update(activityToken, snapshot);
// Live Activity remote start (iOS 17.2+) against the push-to-start token.
await push.start(pushToStartToken, snapshot, {
surfaceId: snapshot.surfaceId,
modeLabel: snapshot.liveActivity.modeLabel,
});
// End the activity. dismissalDateSeconds defaults to now.
await push.end(activityToken, snapshot);
// Alert push (uses toApnsAlertPayload internally; works
// as a fallback for users who have Live Activities turned off).
await push.alert(deviceToken, snapshot);
await push.close(); // tear down the HTTP/2 sessions when shutting down
The SDK validates every snapshot through liveSurfaceSnapshot.safeParse and rejects non-liveActivity kinds for update / start / end / broadcast with a typed InvalidSnapshotError before any network call. alert(), update(), start(), end(), and broadcast() resolve with { apnsId, status, timestamp }; non-2xx responses throw a typed ApnsError subclass per Apple reason. See docs/push.md for the full SDK reference, error taxonomy, and channel/broadcast surface.
The raw HTTP/2 wire shape (APNs endpoints, topic headers, push-type headers, the per-event aps envelope, the iOS 18 broadcast and channel-management endpoints) lives in docs/push.md. scripts/send-apns.mjs builds those requests verbatim if you want to read the protocol top-to-bottom in one file.
4. Manage tokens
Three token kinds, three lifetimes. The backend is responsible for storing and rotating them. (The full lifecycle table (where each token comes from in the harness and how to store each on a backend) lives in docs/push.md#token-taxonomy.)
- Device APNs token: per device, per app install. Used for plain
alertpushes viaclient.alert(deviceToken, snapshot). Rotates rarely. Persist bydeviceId. - Push-to-start token: per user / per
Activity<Attributes>type, returned fromActivity<…>.pushToStartTokenUpdates(iOS 17.2+). Used to sendevent: "start"viaclient.start(pushToStartToken, snapshot, attributes). Subscribe at app mount and re-store whenever a new value arrives. Caveat (FB21158660, Apple-reported): a push-to-start token issued before the user force-quits the app remains valid against APNs but the OS will not actually start the activity until the user re-launches the app. There is no client workaround. Plan rollouts and customer-support scripts accordingly. - Per-activity push token: returned from
Activity.pushTokenUpdatesonce iOS issues it afterActivity.request. Used forevent: "update"/event: "end"viaclient.update/client.end. Discard the token when the activity ends.
The harness in this repo surfaces all three for inspection: the bottom row shows the device APNs token; “All active activities” shows the per-activity push token as it streams in; the push-to-start token is logged through liveActivityAdapter’s onPushToStartToken event.
Smoke-Test The Backend Path Locally
The starter’s APNs script is the protocol-reference smoke tool. It accepts the same flags a backend would set. Once you have an activity token (start one in the harness; the value streams into “All active activities”), exercise updates:
pnpm mobile:push:device:liveactivity -- \
--activity-token=<paste> \
--event=update \
--state-file=./scripts/sample-state.json \
--env=development
Or send the full LiveSurfaceSnapshot-derived state from a fixture:
pnpm mobile:push:device:liveactivity -- \
--activity-token=<paste> \
--event=update \
--snapshot-file=./data/surface-fixtures/active-progress.json \
--env=development
For the iOS 18 broadcast/channel modes:
# Create a channel (returns base64 channel-id)
node scripts/send-apns.mjs --channel-action=create --env=development
# Broadcast on the channel
node scripts/send-apns.mjs --type=liveactivity --channel-id=<base64> \
--event=update --snapshot-file=./data/surface-fixtures/active-progress.json --env=development
# List / delete channels
node scripts/send-apns.mjs --channel-action=list --env=development
node scripts/send-apns.mjs --channel-action=delete --channel-id=<base64> --env=development
For the full set of flags, including --event=start, --push-to-start-token, --stale-date, --dismissal-date, --priority, and --storage-policy, see scripts/README.md and the script reference in docs/push.md#smoke-script-reference. When something fails, docs/troubleshooting.md maps the most common APNs response codes back to causes.
Localization Policy
All string fields on LiveSurfaceSnapshot are pre-rendered for one locale per snapshot. The backend selects the locale (per-user preference, request Accept-Language, etc.) and emits the snapshot in that locale. If the locale changes, send a fresh snapshot. There is no in-place locale switch on the client.
A future LocalizedString shape (e.g. { en: string; "es-MX"?: string }) would arrive in a future major and bump schemaVersion again. v4 stays string-only on purpose; ActivityKit content states are size-bound (4 KB) and shipping every translation per push wastes that budget.
What Stays Stable
LiveSurfaceSnapshotand its TypeScript schema.- The projection helpers (
toLiveActivityContentState,toWidgetTimelineEntry,toControlValueProvider,toNotificationContentPayload) and their kind gates, plustoApnsAlertPayloadin@mobile-surfaces/push. - The
@mobile-surfaces/pushSDK public surface (the exports listed inpackages/push/src/index.ts). Implementation detail (HTTP/2 transport, JWT cache internals) may change without a version bump. - The APNs topic / push-type / priority defaults emitted by the SDK and the script.
What can change without notice:
- The Swift
MobileSurfacesActivityAttributes.ContentStateshape: it must agree withtoLiveActivityContentState’s output, but adding a field is a coordinated change in this repo. - The smoke script’s CLI flags: match the documented set in
scripts/README.md, do not parse the script itself. - The Live Activity adapter at
packages/live-activity/(@mobile-surfaces/live-activity). Production backends should not depend on its internals; they depend only on the snapshot contract and APNs.