Skip to main content

bmsdna-app-native

Python library for FastAPI backends: send push notifications to the BMS app (iOS) and receive its data (location, documents, notification actions, widgets, approvals) — without a database or service of its own. Everything runs either as a direct function call (send_push) or as an APIRouter that you mount in your own backend; you decide how data is stored via callbacks.

Counterpart for the web page: @bmsuisse/app-native (npm). Formats and guides: docs/.

Installation

uv add bmsdna-app-native
# or
pip install bmsdna-app-native

In code:

from bmsdna.app_native import send_push, create_ingest_router

Table of contents


send_push() — push with a banner

from bmsdna.app_native import send_push

sent, detail = await send_push(
    device_token="<apns-device-token>",  # from your on_device_registered callback
    title="New order",
    body="Order 123 is awaiting confirmation",
    data={"path": "/orders/123", "order_id": "123"},  # optional, see below
)
# sent: bool, detail: str | None (error description when sent=False)

A direct function call, not an endpoint — call it from anywhere in your own code as soon as you have an APNs device token (you get it from create_ingest_router(), see below). Raises RuntimeError if the APNs credentials are missing (no config, provider or APNS_* environment variables) or the auth key cannot be found or is not PEM text — catch it if you call this for several devices in a loop.

data conventions that the iOS app evaluates:

Key Effect in the app
data={"webapp_id": "partner-tool"} Assigns the push to the web app with this id (your web app's id in the app) (home list, "Recently used" tabs, combined notifications tab). If the app hosts several web apps, every backend should include this field with every push — otherwise the notification still lands in the notifications tab, but as "Unknown app" with no way to navigate, and silent pushes (see below) are ignored entirely.
data={"path": "/orders/123"} When the user taps the notification, the WebView of the assigned web app navigates to this URL (relative to its url).
data={"actions": [{"id": "confirm", "title": "Confirm"}, ...]} Shows these buttons below the notification in the app's notifications tab — no app-side configuration needed, entirely determined by the payload. When the user taps one, it arrives in create_notification_action_router() (action = the tapped id, data = the remaining fields of this push). There are no buttons directly on the system banner/Lock Screen (iOS would require a category registered with the app in advance).

Cleaning up dead device tokens

When sent=False, send_push()/send_silent_push() return Apple's failure reason in detail. Two of them are permanent; retrying with the same token is pointless:

from bmsdna.app_native import PERMANENTLY_INVALID_TOKEN_REASONS

sent, detail = await send_push(device_token=device.push_token, title=..., body=...)
if not sent and detail in PERMANENTLY_INVALID_TOKEN_REASONS:
    my_store.delete(device_id)  # or at least set push_token=None
  • "BadDeviceToken" — the token format does not match (or sandbox/production mixed up, see APNS_USE_SANDBOX).
  • "Unregistered" — the app was uninstalled on this device.

Important if you manage devices by device_id: The iOS app stores its device_id in the Keychain, not in UserDefaults — so it survives an uninstall + reinstall on the same device (the same device_id comes back). So if, on an "Unregistered" failure, you do not delete the whole device entry (e.g. only the push_token), a later reinstall is automatically matched to the correct record again. If you do not clean up, on the other hand, your device overview grows indefinitely with dead entries.

send_silent_push() — silent push (background refresh)

from bmsdna.app_native import send_silent_push

sent, detail = await send_silent_push(
    device_token="<apns-device-token>",
    data={"webapp_id": "partner-tool", "reason": "new_data"},
)

No banner/sound — sets content-available: 1 + the apns-push-type: background/apns-priority: 5 headers required by Apple. Briefly wakes the app, which then (throttled, by default at most every 15 minutes) reloads the WebView of the web app assigned via webapp_id. If the app hosts several web apps, webapp_id is not optional here — without a recognizable assignment, the app ignores the silent push entirely.

iOS delivers silent pushes only on a "best effort" basis — no real-time guarantee. If your backend sends very frequently, throttle on the server side yourself anyway (the toolkit deliberately keeps no state for this).

create_ingest_router() — receiving device tokens + location

from bmsdna.app_native import (
    DeviceRegistration,
    LocationUpdate,
    WidgetRequestContext,
    create_ingest_router,
)


async def on_device_registered(
    body: DeviceRegistration, context: WidgetRequestContext
) -> None: ...  # store push_token in your own DB, context.user is the caller


async def on_location_received(
    body: LocationUpdate, context: WidgetRequestContext
) -> None: ...  # process live or store in your DB


app.include_router(
    create_ingest_router(
        on_device_registered=on_device_registered,
        on_location_received=on_location_received,
        auth_dependency=require_user,  # required, see "Auth" below
    ),
    prefix="/api",
)

Creates two endpoints under the prefix you choose:

Endpoint Payload (Pydantic model) Callback
POST <prefix>/devices DeviceRegistration on_device_registered
POST <prefix>/locations LocationUpdate on_location_received

DeviceRegistration (bmsdna.app_native/models.py):

Field Type Required Note
device_id str yes stable, anonymous device ID (UUID), generated by the app
push_token str | null no APNs device token
platform "ios" | null no currently always "ios"
user_email str | null no email the app reports for the signed-in user, unverified — null on logout is a deliberate delete

Important: push_token and user_email arrive independently of each other (push registration often happens before login). In your callback, check body.model_fields_set (standard Pydantic attribute) to see which fields were actually sent in this specific call, instead of misreading missing fields as "delete":

async def on_device_registered(body: DeviceRegistration, context: WidgetRequestContext) -> None:
    device = my_store.get_or_create(body.device_id)
    if "push_token" in body.model_fields_set:
        device.push_token = body.push_token
    # Who owns the device comes from the verified login (`context.user`, from
    # your auth_dependency), NEVER from `body.user_email` or `body.device_id`:
    # that is client input, and anyone with a valid login could send any
    # address and receive that person's pushes.
    if isinstance(context.user, EntraUser):
        device.user_email = context.user.email
    my_store.save(device)

body.user_email is only the app's own hint (e.g. null right after a logout). Never route pushes, approvals or documents by it.

LocationUpdate:

Field Type Required
device_id str yes
latitude / longitude float yes
accuracy float | null no
recorded_at datetime | null no
user_email str | null no — sent if the app currently knows a signed-in user, unverified

Arrives as soon as the iOS app reports a change of location (significant location changes from iOS — no fixed time interval, rule of thumb ~500m).

But only within the sending hours that the employee sets in the Info tab of the app (weekdays + time of day, default Mon–Fri 07:00–18:00). Outside of these, simply nothing arrives — so a gap in the data does not necessarily mean that the device is offline or the permission is missing.

create_document_router() — receiving file uploads (scans)

from bmsdna.app_native import ReceivedDocument, WidgetRequestContext, create_document_router


async def on_document_received(
    doc: ReceivedDocument, context: WidgetRequestContext
) -> None: ...  # store doc.data (bytes) in blob storage/DB


app.include_router(
    create_document_router(
        on_document_received=on_document_received,
        auth_dependency=require_user,
        max_bytes=25 * 1024 * 1024,  # default; larger uploads get 413
    ),
    prefix="/api",
)
Endpoint Content-Type Callback
POST <prefix>/documents multipart/form-data on_document_received

Form fields: file (required), device_id (required), user_email (optional), kind (optional, a free-form distinguishing attribute such as "scan"/"signature"/"receipt"). The media of native forms arrive here too, with kind="form" and four more fields: upload_id (a UUID per file, the same one again after a failed attempt), form_id, field_id and context (JSON text). They are in doc.metadata (context already parsed; text that is not a JSON object is answered with 422). Details in the native forms section.

ReceivedDocument (not a Pydantic model but a dataclass — it contains raw bytes that do not go through JSON):

@dataclass
class ReceivedDocument:
    metadata: DocumentMetadata  # device_id, user_email, kind, upload_id, form_id, field_id, context
    filename: str  # base name only, `../` parts are stripped
    content_type: str
    data: bytes

The callback may return a reference to what it stored: a str or int, or a dict with an "id". The response is then {"ok": true, "id": <reference>}, and a native form hands the page that reference. Without a return value (every callback written before native forms) it is {"ok": true} and the page gets the upload_id.

Triggered when the web app in the WebView calls window.webkit.messageHandlers.nativeBridge.postMessage({action: "scanDocument"}) (opens the native VisionKit document scanner), or scanDocument() from @bmsuisse/app-native.

create_notification_action_router() — receiving interactive push buttons

Receives the event when the user taps a button from data={"actions": [...]} (see send_push() above) in the app's notifications tab.

from bmsdna.app_native import (
    NotificationActionReceived,
    WidgetRequestContext,
    create_notification_action_router,
)


async def on_action_received(
    action: NotificationActionReceived, context: WidgetRequestContext
) -> None:
    if action.action == "confirm":
        ...  # e.g. mark the order from action.data["order_id"] as accepted


app.include_router(
    create_notification_action_router(
        on_action_received=on_action_received, auth_dependency=require_user
    ),
    prefix="/api",
)
Endpoint Payload Callback
POST <prefix>/notification-actions NotificationActionReceived on_action_received
Field Type Note
device_id str
action str the id of the tapped entry from data={"actions": [...]} of the original push, e.g. "confirm"/"reject"
data dict | null the remaining custom data from the original push (including the "actions" field itself)

Only actual button taps trigger this — if the user taps the notification itself (no button), that does not count as an action.

Dashboard widgets: feed, actions, send_widget_update()

Puts your web app's widgets on the app's native home dashboard (KPIs, statistics, lists, charts, progress, text, actions). The app must have the .dashboardWidgets capability enabled for your web app and know the feed path (widgetFeedPath). Full format, all fields and examples: docs/dashboard-widgets-guide.md.

from bmsdna.app_native import (
    KpiData,
    KpiWidget,
    WidgetActionReceived,
    WidgetActionResult,
    WidgetRequestContext,
    create_widget_action_router,
    create_widget_feed_router,
    send_widget_update,
)


async def get_widgets(context: WidgetRequestContext):
    return [
        KpiWidget(
            id="open-orders",
            title="Open orders",
            required=True,
            data=KpiData(value=12, unit="pcs"),
        )
    ]


async def on_action(
    action: WidgetActionReceived, context: WidgetRequestContext
) -> WidgetActionResult | None:
    ...  # action.widget_id / action_id / item_id / values
    return WidgetActionResult(message="Done")


app.include_router(
    create_widget_feed_router(get_widgets=get_widgets, auth_dependency=require_user), prefix="/api"
)
app.include_router(
    create_widget_action_router(on_action_received=on_action, auth_dependency=require_user),
    prefix="/api",
)

# Somewhere in your code, when something changes:
await send_widget_update(
    device_token=token, webapp_id="partner-tool", widgets=await build_widgets(user)
)
Endpoint / function Purpose
GET <prefix>/widgets?device_id=… Feed. The app fetches it itself and uses it to replace all of your web app's widgets.
POST <prefix>/widget-actions Button tap on a widget → on_action_received(WidgetActionReceived, WidgetRequestContext). Returns WidgetActionResult or None (= ok).
POST <prefix>/widget-options Results for search fields (type="search") and lazily loaded select fields (select with remote=True) in action forms and in native forms → on_options_requested(WidgetOptionsRequest, WidgetRequestContext). Exactly one of req.widget_id / req.form_id is set. Returns WidgetOptionsResult or directly a list of ActionOption, e.g. from Meilisearch (create_widget_options_router).
send_widget_update(device_token, webapp_id, widgets=None) Silent push: with widgets, they are sent inline (automatically falls back to a feed fetch above MAX_INLINE_WIDGETS_BYTES); without widgets, only a feed fetch.
dump_widgets(widgets) Widgets as JSON-ready dicts in the app format, e.g. for the JS bridge or an endpoint of your own.

WidgetRequestContext.user is the return value of your auth_dependency (see below). Unlike with the other routers, you get it here directly, because widgets are almost always user-specific. The models (KpiWidget, ListWidget, ActionWidget, …) validate the format and serialize to camelCase. In Python you write snake_case (default_enabled=False).

The user can also place the widgets individually on their Home Screen ("web app widget", iOS 17 and later). This needs nothing extra. A button tap from there arrives with source="homeScreen" in WidgetActionReceived.

Native forms and content: NativeForm, NativeContent, uploads, option lookups

A web page can open native iOS sheets: a form (showForm) or content (showContent, text, image, table, map, buttons, …). The page sends the definition, the app renders it and answers once. The backend has three jobs, all with existing building blocks; there is nothing else to mount. Full guide: docs/native-surfaces-guide.md.

1. Serve the definition (optional; the page can also build it itself). NativeForm and NativeContent have the same shape as the TypeScript types and validate the format and the app's limits (MAX_FORM_FIELDS, MAX_CONTENT_BLOCKS, MAX_REPEAT_FIELDS, MAX_THEN_STEPS, …), so typos show up here and not as invalid or tooLarge on the device. A definition beyond a limit raises SurfaceLimitError (what the app answers tooLarge); every other problem is invalid:

from bmsdna.app_native import ActionField, NativeForm, NativeFormSubmit


@app.get(
    "/api/forms/new-customer",
    response_model=NativeForm,
    response_model_by_alias=True,  # camelCase, as the app expects it
    response_model_exclude_none=True,
)
async def new_customer_form() -> NativeForm:
    return NativeForm(
        id="new-customer",
        title="New customer",
        fields=[
            ActionField(id="name", label="Company name", required=True),
            ActionField(id="email", type="email", label="Email"),
            ActionField(id="card", type="photo", label="Business card", max=1),
        ],
        submit=NativeFormSubmit(label="Create customer"),
    )

The page passes what it gets to showForm unchanged. The form fields are ActionField, the same model as in action widgets; it accepts all field types (textarea, email, phone, url, multiselect, segmented, stepper, time, datetime, info, photo, scan, signature, barcode, repeat) and the validation properties (min_length, max_length, min, max, pattern, pattern_message, help). Content blocks are HeadingBlock, TextBlock, ImageBlock, FactsBlock, ListBlock, TableBlock, MapBlock, FileBlock, VideoBlock, CalloutBlock, BadgesBlock, ButtonsBlock, KpiBlock, StatsBlock, ChartBlock, ProgressBlock, FieldsBlock, CardBlock, RowBlock, DividerBlock, SpacerBlock; NativeAction is a button with then steps (OpenPathStep, OpenUrlStep, CopyStep, ShareStep, CloseStep, DeviceStep, ShowFormStep, ShowContentStep). dump_native_form() and dump_native_content() give plain dicts.

2. Option lookups. search fields and remote select fields of a native form use create_widget_options_router with the same request as for widgets, but with form_id instead of widget_id, plus the context of the definition (WidgetOptionsRequest.context). Exactly one of widget_id and form_id is set; existing callbacks that only look at widget_id keep working.

async def on_options(req: WidgetOptionsRequest, context: WidgetRequestContext):
    if req.form_id == "new-customer" and req.field_id == "owner":
        return [ActionOption(value=u.id, label=u.name) for u in await find_users(req.query)]
    ...

3. Media uploads. photo, scan and signature fields are uploaded by the app to the document router (create_document_router, kind="form") before the page gets the form result. The callback returns a reference, the page sends the references to its own endpoint, and resolve_file_refs turns them back:

from bmsdna.app_native import NativeFileRef, ReceivedDocument, resolve_file_refs


async def on_document_received(doc: ReceivedDocument, context: WidgetRequestContext) -> str | None:
    if doc.metadata.kind != "form":
        return None  # a scan: as before
    # Idempotent per upload_id (the app retries): key your storage by it.
    return await uploads.put(owner=context.user.oid, upload_id=doc.metadata.upload_id, doc=doc)


class NewCustomer(BaseModel):
    name: str
    card: list[NativeFileRef] = []


@app.post("/api/customers")
async def create_customer(body: NewCustomer, user: EntraUser = Depends(auth)):
    # 422 naming the references that are unknown, expired or not this user's.
    cards = await resolve_file_refs(body.card, lambda ref: uploads.get(owner=user.oid, ref=ref))
    ...
Name What
NativeFileRef {name, mime, size, ref}: a file of a form as the page sends it to your endpoint
resolve_file_refs(refs, lookup) lookup(ref) returns what you stored or None; result in order, 422 with the unknown refs otherwise
DocumentMetadata.upload_id, .form_id, .field_id, .context the new upload fields
WidgetOptionsRequest.form_id, .context the new lookup fields

Two rules for the store behind it: check the owner when resolving (the reference is client input), and delete uploads that no request ever names after a while (a TTL, for example 24 hours): a user who adds a photo and drops the form leaves a file behind. The in-memory _UploadStore in example/main.py shows the pattern.

Live Activities: send_live_activity_update() & co.

Ongoing processes (order, pickup, job) live on the Lock Screen and in the Dynamic Island. The app must have the .liveActivities capability enabled for your web app. Full flow, format and limits: docs/live-activities-guide.md.

Endpoint / function Purpose
POST <prefix>/live-activities The app reports tokens → on_token_received(LiveActivityTokenReceived, WidgetRequestContext): event="token" (token of an activity), "push_to_start_token" (one token per device, iOS 17.2 and later) or "ended" (create_live_activity_router).
send_live_activity_update(push_token, state, alert_title=None, …) New state (LiveActivityState), highlighted with alert_title.
send_live_activity_end(push_token, state=None, dismiss_at=None) Ends the activity.
send_live_activity_start(push_to_start_token, attributes, state, …) Starts an activity without the app (iOS 17.2 and later).
build_live_activity_message(...), build_live_activity_start_message(...) Only the payload, e.g. for tests or your own APNs client.

Approvals: create_approvals_router(), send_approvals_update()

Pending approvals (vacation, expenses, orders) for the logged-in user. The app shows them at the top of Home under "Waiting for you"; the user approves or rejects with a swipe, without opening your web app. The app must have the .approvals capability enabled for your web app. Format, fields and example: docs/approvals-guide.md.

Endpoint / function Purpose
GET <prefix>/approvals get_approvals(WidgetRequestContext) returns a list of Approval. Replaces all of your web app's approvals on the device.
POST <prefix>/approval-decisions on_decision(ApprovalDecisionReceived, WidgetRequestContext): decision is "approve" or "reject", plus an optional comment. Returns None or ApprovalDecisionResult(ok=False, message=…). Check permissions yourself.
send_approvals_update(device_token, webapp_id) Silent push; the app reloads the feed immediately.

Urgent pushes and critical alerts: send_critical_alert()

send_push(..., interruption_level="time-sensitive") also gets through a Focus mode and the scheduled summary, e.g. "Driver waiting at the loading dock". This does not need approval from Apple.

For real emergencies (evacuation, severe weather, security incident) there are critical alerts. They ring even with the mute switch on and in "Do Not Disturb", and in the app they additionally appear full-screen:

from bmsdna.app_native import send_critical_alert

sent, detail = await send_critical_alert(
    device_token=token,
    webapp_id="partner-tool",
    title="Evacuate Building A",
    body="Leave immediately via the east stairwell. Assembly point: car park P2.",
    acknowledge_label="I am safe",  # None = no acknowledge button
    data={"alert_id": "evac-2026-09-27"},
)

When the user taps the acknowledge button, the app reports it to create_notification_action_router() with action="acknowledge" and your data fields. That way you see who has seen the alert.

Critical alerts only take effect once Apple has granted the app the entitlement. Until then, the push arrives as a normal notification; the full-screen alarm in the app and the acknowledgement still work. The user can turn off critical alerts in the iOS settings at any time. Use them only for real emergencies, otherwise Apple will reject the entitlement or revoke it.

Auth: auth_dependency

Every create_*_router() requires auth_dependency, a normal FastAPI dependency that is added to every route of the router. It either raises HTTPException(401/403) or returns the authenticated user, which arrives in your callbacks as context.user. What it checks is up to you: a session cookie, an Authorization: Bearer token, an API key. For the Entra ID token of the app there is a ready-made dependency, see the next section.

from fastapi import Header, HTTPException


async def require_user(authorization: str = Header(...)) -> dict:
    claims = verify_access_token(authorization.removeprefix("Bearer "))  # your code
    if claims is None:
        raise HTTPException(status_code=401)
    return claims  # arrives as `context.user`

Only for local development and tests there is an explicit opt-out, auth_dependency=insecure_no_auth. Without a check, anyone who can reach the endpoints can register devices, upload data and act on behalf of any user.

Identity comes from context.user, not from the request body. The user_email fields in the payloads are what the app reports. They are client input and anyone can send any address. Use them at most as a hint and take the caller from your auth_dependency. The same goes for ids in the payload (approval_id, data of a push action, widget_id): check that context.user may act on them.

What the app sends along: For web apps with the identity capability, the iOS app sends the Entra ID token of the user's sign-in as Authorization: Bearer <token> with every native call to your backend (and attaches it to the fetch/XHR calls of your page). Web apps without identity (for example pages on SharePoint) get no token; for them the app forwards the cookies of the web app's host, as long as apiBaseURL has the same host as the web app.

Sign-in with the Entra token: create_entra_auth_dependency()

The user signs in once in the app, and no web app asks again: your backend checks the token and takes the user from it. Everything is configurable, nothing is hard-wired.

import os

from bmsdna.app_native import (
    EntraTokenVerifier,
    SignedSessionCookie,
    create_entra_auth_dependency,
    create_widget_feed_router,
)

verifier = EntraTokenVerifier.from_env()  # ENTRA_TENANT_ID, ENTRA_AUDIENCE

require_user = create_entra_auth_dependency(
    verifier,
    session=SignedSessionCookie(secret=os.environ["SESSION_SECRET"]),
    resolve_user=find_user,  # optional: map the identity to your own user
)

app.include_router(
    create_widget_feed_router(get_widgets=get_widgets, auth_dependency=require_user),
    prefix="/api",
)


@app.get("/api/me")  # the app's "check backend" call and your page's first call
async def me(user=Depends(require_user)):
    return {"email": user.email}
Setting Meaning
ENTRA_TENANT_ID Directory (tenant) ID, a GUID. common and similar are refused.
ENTRA_AUDIENCE Client ID of the iOS app's registration (the aud of the token); several separated by commas.
SESSION_SECRET Signs the session cookie. Random, at least 32 bytes. Your own variable name, passed to the cookie.
EntraTokenVerifier(...) leeway (60 s), jwks_cache_seconds (1 h), jwks_min_refresh_seconds (60 s), jwks_timeout_seconds (10 s)
SignedSessionCookie(...) name (bms_session), max_age (8 h), same_site (lax), secure (True)

What the dependency answers:

Request Answer
Valid token accepted, context.user is the EntraUser (oid, email, name), cookie set
Invalid, expired or foreign token 401, no redirect. A valid cookie does not rescue an invalid token
No token, valid session cookie accepted
Nothing 401
Valid token, but resolve_user returns None 403
Microsoft's signing keys cannot be loaded 503

A token is the proof of who it is, not of what the user may do: roles and permissions stay in your backend, and resolve_user is the place to look the user up. The dependency also works for WebSocket routes (the iOS app sets the header in the handshake; a browser cannot, see the guide).

Two things to know about the cookie: it is set through the response FastAPI builds from your return value, so a route that returns a Response itself (JSONResponse, RedirectResponse, FileResponse) must call copy_session_cookie(request, response); and a WebSocket handshake cannot set one. Use another SESSION_SECRET for every environment.

docs/entra-token-guide.md explains what is checked, the cookie exchange, the limits in the web view, and how to migrate a backend that has a cookie login today.

Setting up APNs access

Once, in the Apple Developer Portal:

  1. Register an App ID: Certificates, Identifiers & Profiles → Identifiers → "+" → App IDs. Enable the Push Notifications capability.
  2. Create an auth key (.p8): Certificates, Identifiers & Profiles → Keys → "+" → check "Apple Push Notifications service (APNs)" → generate the key and download it once (it cannot be downloaded again). Put it straight into a secret store (see below), not into the repo.
  3. Set environment variables (cp .env.example .env for the local example, otherwise your backend's real environment variables):
    • APNS_KEY — contents of the .p8 file. Line breaks may be written as \n. To put it on a single line for a local .env: awk 'NF {printf "%s\\n", $0}' AuthKey.p8
    • APNS_KEY_ID — the 10-character key ID from the portal
    • APNS_TEAM_ID — your team ID (Portal → Membership)
    • APNS_BUNDLE_ID — the app's bundle ID
    • APNS_USE_SANDBOX — true for debug builds from Xcode, false for TestFlight/App Store

Credentials at runtime (database, admin UI)

If the key is not in the environment but, say, encrypted in your database and an admin may change it without a restart, pass the credentials yourself. Every send_* function takes an optional config:

from bmsdna.app_native import ApnsConfig, send_push, set_apns_config_provider

config = ApnsConfig(
    key=pem_text,          # contents of the .p8 file (`
` escapes are accepted)
    key_id="ABC123DEFG",
    team_id="TEAM123456",
    bundle_id="ch.example.app",
    use_sandbox=False,
)
await send_push(device_token=token, title="Hi", body="...", config=config)

# or once at startup: called before every send, so keep it cheap
async def load_apns_config() -> ApnsConfig:
    return await my_settings.apns()  # e.g. cached for a few seconds

set_apns_config_provider(load_apns_config)  # None removes it again

Per send, the first match wins: explicit config > provider > APNS_* environment variables. Nothing is cached for good: clients are cached per credential set (key ID, team, bundle ID, sandbox flag and a SHA-256 of the key), so after a key change the next send builds a new client and drops the old one. ApnsConfig is immutable and keeps the key out of its repr. A missing configuration still raises RuntimeError. live_activity_topic(config) takes the bundle ID from the config; without one it reads APNS_BUNDLE_ID (a provider is async, so it is not consulted there).

Storing the key in production

The .p8 key applies to the whole Apple team, not just one app. Whoever has it can send pushes to any device with one of our apps. Therefore:

  • Storage: Only in a secret store (e.g. Azure Key Vault), never in the repo, in plain-text pipeline variables or in chats. The backend gets it as APNS_KEY from the secret store, e.g. via a Key Vault reference in the app settings and a managed identity.
  • Access: Only the backends that send pushes get read access to the secret (to the individual secret, not the whole store). People only need it for rotation.
  • Few keys: Apple allows only two active APNs keys per team. All backends therefore share the same key. Every new backend that gets it is a deliberate decision.
  • Rotation (if a leak is suspected, when people with access leave):
    1. Generate a second key in the Developer Portal.
    2. Store it in the secret store as a new version of the secret, update APNS_KEY_ID in all backends and restart them.
    3. Only once all backends send with the new key, revoke the old key in the portal. Device tokens remain valid; the apps notice nothing.
  • Not secret are APNS_KEY_ID, APNS_TEAM_ID and APNS_BUNDLE_ID. Without the key they are of no use to anyone.

Local example & tests

example/main.py shows the routers and send functions end-to-end with in-memory storage (no Postgres/Docker needed):

The example is a local demo, not a deployment template: it refuses to start without Entra sign-in (ENTRA_TENANT_ID, see .env.example), and it keeps data in memory without per-user ownership checks. Do not expose it to a network.

cp .env.example .env   # fill in APNs and Entra values
uv run --group dev granian --interface asgi example.main:app --host 127.0.0.1 --port 8000 --reload

To try the curl calls below without any login (own machine only), opt out explicitly:

ALLOW_INSECURE_NO_AUTH=1 uv run --group dev granian --interface asgi example.main:app --host 127.0.0.1 --port 8000 --reload
curl -X POST http://localhost:8000/api/devices \
  -H "Content-Type: application/json" \
  -d '{"device_id": "abc", "push_token": "<real-apns-token>"}'

curl -X POST "http://localhost:8000/api/notify/abc?title=Hello&body=Test"
curl -X POST "http://localhost:8000/api/notify/abc/order-request?order_id=123"
curl -X POST "http://localhost:8000/api/notify/abc/refresh"

Tests (fake callbacks, no real APNs credentials needed):

uv run python -m pytest

License

MIT

Metadata

Release files for bmsdna-app-native 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for bmsdna-app-native 0.4.0
File Size Uploaded
bmsdna_app_native-0.4.0.tar.gz 67.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for bmsdna-app-native 0.4.0
File Interpreter ABI Platform
bmsdna_app_native-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 129.8 kB

Release files / bmsdna_app_native-0.4.0.tar.gz

Download URL bmsdna_app_native-0.4.0.tar.gz
Size 67.1 kB
Tags Source
SHA-256 checksum
How to use checksums
0247ee61c7380f88b081d5aeef92d1bda320113ff18cd38721bb610c7202bba1
BLAKE2b-256 checksum
How to use checksums
b2d49d46312d58f44a88f43b15f386be63d2c9ef590e82c0dfec768f633b1b41
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release files / bmsdna_app_native-0.4.0-py3-none-any.whl

Download URL bmsdna_app_native-0.4.0-py3-none-any.whl
Size 62.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
446001c55b965829634c896974cf9a7761a755b7b73f0d56ab4db2c0e4dacfda
BLAKE2b-256 checksum
How to use checksums
5ca43fd07782a94cb8f97ba1fbdda72a37bfa4beb98e80264b8ed3d57bd0f5a9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release history Release notifications | RSS feed

0.5.0

2 release files

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page