Concepts
Notifications
Your app calls `ellivate.notify(...)` from a server handler. Ellivate forwards the push to the user's phone via the mobile shell's already-registered push tokens. You never import expo-notifications or wire APNs.
The one pattern
# Python (Flask / FastAPI / Streamlit)
ellivate.notify(
title="Reservation available",
body="Friday 7pm at Lazy Bear.",
deep_link="/results",
)// JS / TS — server-side only
// (Next.js server action / server component / route handler)
await ellivate.notify({
title: "Reservation available",
body: "Friday 7pm at Lazy Bear.",
deepLink: "/results",
});That call returns immediately. Behind the scenes, the Ellivate API looks up every Expo push token registered for the target user, fans the payload out via Expo's push service, Apple/Google deliver it. The user's phone shows a banner. Tapping it opens your app at deepLink.
Why server-only
A client-callable notify() would let any visitor who can open the app craft a request that pushes to anyone else with the app — a perfect spam vector. So ellivate.notify only works from server contexts (Next.js server actions, Flask routes, FastAPI handlers). The SDK throws synchronously if called from a 'use client' component or vanilla browser script, and the publish-time scanner flags the same pattern in source code.
If you want a notification fired by a user gesture, route the gesture through a server action:
// app/page.tsx — client UI
"use client";
import { remindMe } from "./actions";
export default function Page() {
return <button onClick={() => remindMe("123")}>Remind me</button>;
}
// app/actions.ts — server action calls notify
"use server";
import { ellivate } from "../ellivate-client";
export async function remindMe(refillId: string) {
await ellivate.notify({
title: "Prescription due",
body: "Time to refill.",
deepLink: `/refills/${refillId}`,
});
}Why no native APIs
Apple's App Store guideline 4.7 (updated November 2025) prohibits apps that host third-party HTML5 content from exposing iOS frameworks to that content via JavaScript bridges. The Ellivate mobile shell respects this — it doesn't inject any window.Ellivate* API surface that proxies to expo-notifications, expo-haptics, expo-camera, etc.
That makes ellivate.notify the only path for published apps to reach a user's lock screen. You can't reach for OneSignal, Firebase Cloud Messaging, web-push, or the browser Notification API and have anything happen — those all need infrastructure (service worker registration, native modules) the published app sandbox can't provide.
The shell still does normal native things on its OWN UI chrome (push registration, photo picker for profile pics, etc.). It's just the published apps that go through the server-mediated path.
Targeting
The to field decides who gets the push.
to | Recipient | Most common when… |
|---|---|---|
| omitted | The current viewer. | A user clicked something that should ping them later. |
"owner" | The app's owner. | A bot/cron found something the owner asked it to watch for. |
{ username | userId | email } | A specific user. | Notifying a household member or shared collaborator. |
{ spaceId: "..." } | A specific Space's chat, rendered as a card. | Posting into a Space the tool is attached to. The tool must already be attached to that Space. |
{ space: "current" } | The Space the tool was opened from (resolved automatically). | Posting back into the Space the viewer came from. Loud-fails if the tool wasn't opened from a Space. Prefer this over a hardcoded spaceId. |
The target user must be the app owner OR have an active AppShare for this app — you cannot notify arbitrary users just because you know their email. Sharing must already be in place; an unauthorized target returns a 404.
Posting cards into a Space
When you target a Space — either a specific { spaceId } or { space: "current" } — the notification lands in the Space's chat as a card instead of (only) a push. Pass an optional card to give it structure: labeled fields, a tappable actionPath / actionLabel that opens a path inside your app, and a thumbnailUrl. The card payload is honored only when targeting a Space — it's ignored for user/owner targets.
{ space: "current" } resolves the Space server-side from the viewer token, so the tool never has to know — or hardcode — which Space it's running in. If the tool wasn't opened from a Space, the call loud-fails with a 400 rather than posting somewhere wrong. Reach for it whenever the intent is “post this back into the Space I came from.”
# Python — post a card into the Space this tool was opened from
ellivate.notify(
title="Trip booked",
body="Friday 7pm at Lazy Bear.",
to={"space": "current"},
card={
"fields": [
{"label": "When", "value": "Fri 7:00 PM"},
{"label": "Party", "value": "4"},
],
"actionPath": "/reservations/42",
"actionLabel": "View reservation",
"thumbnailUrl": "https://example.com/lazy-bear.jpg",
},
)Rate limits
Three caps, all enforced server-side:
- 5 / hour, per (app, sender, recipient) pair. One app can't push the same user more than ~once every 12 minutes. Past that, the user wants to throw their phone.
- 50 / day, per (app, sender, recipient) pair. Long-tail cap for polling-heavy use cases (deal alerts, reservation watchers).
- 100 / hour, per app across all recipients. Catches mass-broadcast bugs (a stray
for user in users: notify(user)) that wouldn't trip the per-pair limits.
Over-quota throws a clear error with retryAfterSeconds. The throttled call is recorded for audit (so you can see what got dropped), but no push is sent.
Deep links
deepLink is a path inside your app, not a full URL. The mobile shell prepends the app's canonical URL when the user taps:
ellivate.notify({
title: "Reservation available",
body: "Friday 7pm at Lazy Bear.",
deepLink: "/results?match=42", // ← path
});
// User taps the notification on their phone.
// Mobile shell opens: <appCanonicalUrl>/results?match=42Don't pass a full URL. The shell needs the path so it can route through its normal handoff/auth flow before landing the WebView on your route.
Privacy: the lock screen is public
Notification titles and bodies render on the lock screen before the user authenticates. Don't put PII or confidential details there. Use generic copy ("Reservation available") and let the user open the app to see the specifics.
What about Web?
Today, ellivate.notify delivers to mobile push tokens registered by the Ellivate mobile app. If a target user is web-only (hasn't opened the mobile app and accepted push permissions), the call returns { delivered: 0, skipped: 1, throttled: 0 } — the call was valid, the user just isn't reachable this way yet.
Web Push parity (via service workers + VAPID) is on the roadmap. For the household / friend-group tier Ellivate targets, mobile push covers most of what builders want.
Pairing with scheduled handlers
The classic notification pattern — “poll OpenTable every 10 minutes; ping me when the slot opens” — is what scheduled handlers (ellivate.schedule) are for. Schedule a recurring handler that runs server-side without a viewer in front of it, and have the handler call ellivate.notify({ to: "owner", ... }) when the condition trips:
# Python
ellivate.schedule.cron(
cron="*/10 * * * *",
handler="check_opentable",
)
# Inside the handler:
def check_opentable():
available = poll_opentable()
if available:
ellivate.notify(
title="Reservation available",
body=f"Friday {available} at Lazy Bear.",
to="owner",
deep_link="/results",
)Schedules don't need a viewer — they fire on Ellivate's clock and target the app owner (or any specific user via the to field) without one being signed in. See the JS SDK / Python SDK reference for the full ellivate.schedule surface.
What's next
- JavaScript SDK reference — type signatures and complete options.
- Python SDK reference — same surface, Python signatures.
- Auth model — how the viewer token reaches your server code in the first place.