> For the complete documentation index, see [llms.txt](https://basedapp.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://basedapp.gitbook.io/docs/integrations/mini-apps-platform/16-env-setup.md).

# Env-var setup (APNs / FCM)

End-to-end setup walkthrough for the gateway's notification endpoints. Covers four stacks:

1. **Partner push API** (`/api/partners/notifications/push`) — uses Expo Push Service. **No new credentials** needed beyond what's already configured for first-party pushes.
2. **iOS Live Activities** push-to-LA — requires an APNs auth key.
3. **Android Live Updates** FCM data messages — uses the existing `firebase-admin` credentials already configured.
4. **Mobile host** — already wired, no new env vars.

If you only care about Phase 1 (partner push), skip to [Sanity check](#sanity-check). The rest is for Live Activities.

## Where the env vars live

| Stack                                       | Local dev                              | Production                                       |
| ------------------------------------------- | -------------------------------------- | ------------------------------------------------ |
| Gateway (`based-one-mono/apps/trading-web`) | `.env.local` in the trading-web folder | Vercel env vars on the production deploy         |
| Mobile host                                 | Per-tenant `.env.based` / `.env.hyena` | Compile-time `--dart-define` from `make build-*` |
| Partners                                    | Their own infra                        | Their own infra                                  |

The gateway is what dispatches every push, so almost all secrets live there. The mobile host only needs to know which gateway to talk to (already configured via `BASED_TRADING_API_URL`).

***

## Phase 1 — Partner push (already configured)

The push API at `/api/partners/notifications/push` reuses the existing `NotificationService`, which uses `expo-server-sdk` to send through Expo Push Service (which proxies to APNs and FCM under the hood). If first-party pushes work today, partner push already works.

### What partners need (issued by you)

For each whitelisted partner, mint an `mp_*` API key:

```sh
curl -X POST https://app.based.one/api/admin/mini-app-partners \
  -H "Authorization: Bearer <admin-jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "miniAppId":   "fun.pull.gacha",
    "displayName": "Pull"
  }'
```

The response includes `apiKey: "mp_<32-hex>"` exactly once. Save it immediately — there's no second chance to retrieve it. Hand to the partner over a secure channel (1Password, Signal).

The partner sets it on their backend as e.g. `BASED_PUSH_API_KEY` and signs requests with `Authorization: Bearer mp_*`.

***

## Phase 3 — iOS Live Activities (APNs)

Live Activities use Apple Push Notification service's special push-to-LA flow. We use **Token Auth** with a `.p8` key (NOT certificate auth — Apple recommends token auth for new bundles and it makes secret rotation cleaner).

### Step 1 — Create the APNs key in Apple Developer

1. Go to <https://developer.apple.com> → **Account** → **Keys**.
2. Click `+` to create a new key.
3. Name it `Based App APNs Auth Key` (anything works).
4. Tick **Apple Push Notifications service (APNs)**.
5. *Continue → Register*. **Download the `.p8` file** — you can only download it ONCE. Save to 1Password.
6. Note the **Key ID** (10 chars, e.g. `ABC1234567`).
7. Note the **Team ID** (10 chars — top-right of the developer portal, also visible in your Xcode project's signing tab).

You should now have:

* `AuthKey_ABC1234567.p8` file
* Key ID: `ABC1234567`
* Team ID: `XYZ1234567`
* Bundle ID: `one.based.app` (or whatever's in your `Runner` target's `CFBundleIdentifier`)

### Step 2 — Add to Vercel env (production)

Navigate to your Vercel project → **Settings** → **Environment Variables**. Add four variables for the **Production** environment:

| Name             | Value                                                                                                                                                                                    |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APNS_KEY_P8`    | Paste the entire `.p8` file contents, including the `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines. Vercel handles multiline values fine; just paste with newlines. |
| `APNS_KEY_ID`    | `ABC1234567` (your 10-char Key ID)                                                                                                                                                       |
| `APNS_TEAM_ID`   | `XYZ1234567` (your 10-char Team ID)                                                                                                                                                      |
| `APNS_BUNDLE_ID` | `one.based.app` (must match Runner's bundle id)                                                                                                                                          |

Do **NOT** set `APNS_USE_SANDBOX` in production (default is the production endpoint).

For TestFlight builds, add the same four vars to the **Preview** environment AND set `APNS_USE_SANDBOX=true`.

### Step 3 — Add to local dev

In `~/based-one-mono/apps/trading-web/.env.local` (create if absent — the file is gitignored):

```
APNS_KEY_P8="-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
...
-----END PRIVATE KEY-----"
APNS_KEY_ID=ABC1234567
APNS_TEAM_ID=XYZ1234567
APNS_BUNDLE_ID=one.based.app
APNS_USE_SANDBOX=true
```

When `APNS_*` is missing in dev, the gateway logs a warning and no-ops the dispatch (partners' endpoints still 200 — no broken local flows).

### Step 4 — Verify

```sh
# Trigger a push-to-LA on a real iPhone running a TestFlight build.
# After starting a Live Activity from Pull.Fun, run on the gateway:

curl -X PATCH https://app.based.one/api/partners/live-activities/<activity-id> \
  -H "Authorization: Bearer mp_<partner-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "state": {
      "stages": ["Confirming","Processing","Completed"],
      "currentStage": 2,
      "progress": 1.0,
      "statusText": "Done"
    }
  }'
```

Lock screen card should update within \~2 seconds. If it doesn't, look at the gateway logs — `sendPushToLa` returns a status code from APNs which we surface in the error field.

***

## Phase 3 — Android Live Updates (FCM)

Uses the same `firebase-admin` setup the gateway already uses for everything else Firebase. Most likely already configured — verify with `vercel env ls`.

### Required env (already configured for Phase 1 + first-party)

| Name                                  | Value                                                                                                            | Source                                                                                                        |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_FIREBASE_PROJECT_ID`     | `based-app-prod` (or your Firebase project id)                                                                   | Firebase console → Project settings → General                                                                 |
| `FIREBASE_CLIENT_EMAIL`               | `firebase-adminsdk-xxxxx@<project-id>.iam.gserviceaccount.com`                                                   | Firebase console → Project settings → Service accounts → "Generate new private key" → email field of the JSON |
| `FIREBASE_PRIVATE_KEY`                | The `private_key` field from the same JSON, with `\n` escapes preserved as literal `\n` (gateway code unescapes) | Same Firebase service-account JSON                                                                            |
| `NEXT_PUBLIC_FIREBASE_DATABASE_URL`   | `https://<project-id>.firebaseio.com`                                                                            | Firebase Realtime Database settings (even if you don't use Realtime DB; firebase-admin requires it)           |
| `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET` | `<project-id>.appspot.com`                                                                                       | Firebase Storage settings                                                                                     |

If first-party pushes deliver to Android devices today, all of the above are already set. The Live Update FCM dispatcher reuses `getFirebaseAdmin()` from `lib/firebaseAdmin.ts` — no new credentials.

### Verify

```sh
# Trigger from the same `PATCH /api/partners/live-activities/<id>`
# request as iOS — the dispatcher forks on the registered token's
# platform. On Android the gateway sends a high-priority,
# data-only FCM message.

# Mobile host's `firebaseMessagingBackgroundHandler` (in
# notification_service.dart) detects the `miniAppId` + `template`
# data fields and updates the ongoing notification.
```

The FCM data fields are flattened from the Live Activity state. You can inspect them in Android Studio's Logcat with the filter `tag:Notifications`.

***

## Mobile host — already wired

`MiniAppScreen.fromUrl(...)` reads the user's Shifu/SIWE JWT via `AuthInterceptor.getShifuToken()` and passes it to `HostMiniAppLiveActivity` as `tokenRegistrationAuth`. The token registration URL is built from `Env.tradingApiUrl` (already configured per-tenant in `.env.based` etc.).

No new env vars on the mobile side. Just rebuild (`make build-ios-based`, `make build-aab-based`).

***

## Sanity check

After setting up env vars, run these checks:

### Gateway

```sh
# Mint a test partner key (admin-only)
curl -X POST https://app.based.one/api/admin/mini-app-partners \
  -H "Authorization: Bearer <admin-jwt>" \
  -d '{"miniAppId":"test.partner","displayName":"Test"}'

# Save apiKey from response, then send a push:
curl -X POST https://app.based.one/api/partners/notifications/push \
  -H "Authorization: Bearer mp_..." \
  -H "Content-Type: application/json" \
  -d '{
    "recipients":{"emails":["you@yourdomain.com"]},
    "title":"Hello",
    "body":"Test push from partner API"
  }'
```

Notification arrives on devices logged in as `you@yourdomain.com` with the title `[Test] Hello`.

### iOS Live Activity (real device required)

```sh
# 1. From Pull.Fun mini-app, trigger a flow that calls
#    `based.liveActivity.start(...)`. Lock-screen card appears.
#
# 2. Inspect the gateway's MiniAppLiveActivityToken table:
psql "$DATABASE_URL" -c "select * from \"MiniAppLiveActivityToken\" order by \"createdAt\" desc limit 5;"
#    Should see a row with platform=ios and your activityId.
#
# 3. Use the activityId from that row:
curl -X PATCH https://app.based.one/api/partners/live-activities/<activityId> \
  -H "Authorization: Bearer mp_..." \
  -H "Content-Type: application/json" \
  -d '{"state":{"stages":["A","B","C"],"currentStage":2,"progress":1,"statusText":"Done"}}'
#    Lock-screen card updates within ~2s.
```

### Android Live Update

Same flow — start a Live Activity from a mini-app on Android, verify the row in `MiniAppLiveActivityToken` has `platform=android`, PATCH the activity, watch the ongoing notification update.

***

## Rotation

* **APNs key**: rotate every 12 months. Create a new key in Apple Dev portal, update `APNS_KEY_P8` + `APNS_KEY_ID`, redeploy. Old key keeps working until you revoke it from the portal.
* **Partner `mp_*` key**: rotate when a partner reports compromise. `DELETE /api/admin/mini-app-partners/<id>` soft-revokes; mint a new one with `POST`.
* **Firebase service account**: rotate via Firebase console → Service accounts → "Manage service account permissions" → generate a new key, update the three Firebase env vars, redeploy. Old key keeps working until removed in the IAM panel.

***

## Where things go wrong

| Symptom                                                  | Likely cause                                                                                                                                                                                                                                                                 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phase 1 push 200's but no notification arrives           | Partner's recipient resolution turned up zero users in tenant. Check `MiniAppPushLog.recipientCount`.                                                                                                                                                                        |
| iOS Live Activity stays static after partner PATCH       | No row in `MiniAppLiveActivityToken` for the activity id. The mini-app needs to call `based.liveActivity.start()` in-app first; that's what causes ActivityKit to issue the push token.                                                                                      |
| iOS Live Activity push delivered=true but doesn't update | APNs delivered the push but ActivityKit dropped it. Usually means the bundle id in `APNS_BUNDLE_ID` doesn't match the `Runner` target's `CFBundleIdentifier`, OR the topic header is wrong (we always set `<bundle-id>.push-type.liveactivity`).                             |
| Android Live Update never arrives                        | Either the token isn't registered (check `MiniAppLiveActivityToken` for a row with `platform=android`), or the FCM data message was dropped because Android battery-optimised the app. The latter is normal on doze; surface a non-data fallback for critical state changes. |
| `429 Rate limit exceeded`                                | Partner is sending faster than 60/min. Crank `MiniAppPartner.rateLimitPerMinute` for trusted partners via direct DB update.                                                                                                                                                  |
