# FCM setup (Android and web)


To deliver to Android — and to web, which also goes out through Firebase — OpenPush needs a Firebase Cloud Messaging credential. It uses the **HTTP v1 API with a service-account JSON only**. There is no legacy server key path anywhere in the server.

## When to use

Do this once per app before any Android device can receive a push. If you also deliver to browsers, read [web-push.md](web-push.md) — web sends use *this same credential*, uploaded in a specific place. iOS uses a separate credential; see [platform-setup-apns.md](platform-setup-apns.md).

## Prerequisites

- A Firebase project with Cloud Messaging enabled, containing your Android app
- Permission in Google Cloud to create a service account key for that project
- Your OpenPush app and console access with the `manager` role or higher

## The one thing that catches everyone

> **Upload the Firebase service account under Android — even if you only care about web.**
>
> The sender reads the **Android** platform credential and nothing else. A service account uploaded under **Web** is stored, makes `webpush-config` work, and turns the Web readiness badge green — and is then **never read by any send**. Every actual delivery falls back to a platform-wide environment credential, or fails with "no FCM credentials", while the console still shows web as configured.
>
> If web is your only channel, upload the service account under Android anyway. Use the Web credential slot for the VAPID public key, site URL and `firebaseConfig` that browsers need, not for the sending credential.

## Step 1 — create the service account key

In the Google Cloud console for your Firebase project, create (or reuse) a service account with the Firebase Cloud Messaging sender role and download a **JSON key**.

OpenPush validates the file at upload and requires all of the following:

| JSON field | Requirement |
|---|---|
| `type` | Must be exactly `service_account` |
| `project_id` | Present — this is the project OpenPush sends to |
| `client_email` | Present — the service account identity |
| `private_key` | Present, and must look like a PEM private key |

An OAuth client JSON, a Firebase config object, or a truncated paste is rejected at upload rather than accepted and failing later at send time.

## Step 2 — upload it under Android

Uploading a platform credential is a **console action**: open your app's **Settings → Platforms → Android** and upload the JSON. There is no `/v1` route for uploading platform credentials.

**Verify:** `GET /v1/apps/{app_id}/settings` reports Android as ready under `platforms`.

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c/settings \
  -H "X-OP-API-Key: $OPENPUSH_REST_KEY"
```

The platform-wide answer — whether *any* FCM credential is configured — is on `GET /healthz` as `fcm`. Per-app readiness is the settings response above.

## How the platform-wide fallback behaves

There is one more place an FCM credential can come from, and it is worth understanding even though it is not yours to set. OpenPush can hold a single platform-wide FCM credential, configured by the OpenPush team through the environment, which is used by any app that has not uploaded its own:

| Variable | Meaning |
|---|---|
| `FCM_SA_JSON` | The service-account JSON contents |
| `FCM_SA_PATH` | A path to the JSON file, as an alternative |

**Your app's own credential always takes precedence**, and uploading one is the supported path.

This fallback is also why a misplaced credential can *look* like it works: an app whose service account went into the Web slot silently falls back on the platform-wide credential, which may point at an entirely different Firebase project.

## How OpenPush talks to FCM

- **HTTP v1 only.** Requests go to `https://fcm.googleapis.com/v1/projects/{project}/messages:send` with the `firebase.messaging` OAuth scope. There is no `key=AAAA…` legacy path and no `fcm/send` endpoint in the server.
- OAuth access tokens are refreshed only when invalid, under a per-credential lock — roughly once per campaign, not once per device.

### The message OpenPush builds

Every OpenPush Android push is a **data-only** message:

```json
{
  "message": {
    "token": "…",
    "data": { "title": "…", "body": "…", "op_message_id": "msg_01hq…" },
    "android": { "priority": "HIGH", "ttl": "3600s", "collapse_key": "…" }
  }
}
```

- `data` carries the whole payload, string-coerced. Objects and arrays are serialized as deterministic JSON.
- `android` carries exactly three things: `priority` (`HIGH` by default, `NORMAL` otherwise), `ttl` as a duration string, and `collapse_key`. When no TTL is set the field is omitted entirely, so Google's own four-week default applies.
- A `notification` block is set **only** for devices carried over by the legacy OneSignal migration bridge, and even then only `title` and `body`.

Nothing else is set: no `android.notification.channel_id`, icon, colour, sound, tag, click action, ticker or image; no `restricted_package_name`; no `apns` or `webpush` sub-config; no `fcm_options`; no topic or condition targeting.

This is deliberate. A data-only message has no sender-side home for a notification channel or an accent colour, so your app draws the notification and the composer's Android options ride as `op_android_*` keys inside `data`. See [sdk-android.md](sdk-android.md) for the key names and how to read them.

The FCM data envelope is 4 KB. If a rendered payload exceeds it the server truncates deterministically — body first, then title — and marks the payload with `op_render_truncated`. TTL is capped at 28 days at the API boundary.

## Error behavior

| FCM response | What OpenPush does |
|---|---|
| `NOT_FOUND` or `UNREGISTERED` | Marks that subscription **status `-10`** — uninstalled or token expired |
| `429`, `500`, `502`, `503`, `504` | Records a retry on the delivery row only; the subscription is never changed |
| A transport error | Same — delivery row only |
| An OAuth refresh failure | Treated as a **configuration fault**: the fan-out stops and **no device is marked unreachable** |
| Anything else | Recorded on the delivery row with FCM's status |

A per-device fault never raises. A credential fault stops the send instead of damaging your audience — a broken service account cannot mass-unsubscribe anyone.

## Web delivery through the same credential

Web subscriptions route through this same FCM v1 path, using the browser's FCM web registration token. Web devices receive the same data-only payload as Android; no `webpush` sub-config is ever set. OpenPush has no VAPID or Web Push Protocol sender of its own. The full picture, including what to put in the Web credential slot, is in [web-push.md](web-push.md).

## Limits

- **HTTP v1 service-account JSON only.** No legacy server key, no API key.
- **Console-only upload.** Platform credentials are not exposed on `/v1`.
- **The Web platform credential is never used for sending.** Upload the service account under Android.
- Data-only messages: FCM will not draw a notification for you, and your app must render every push it receives — including when the app is backgrounded or killed.
- A force-stopped Android app receives nothing until the user launches it again. That is an Android behavior, not an OpenPush one.
- No topic or condition targeting. OpenPush targets by segment and by subscription; see [segments.md](segments.md).
- TTL is capped at 28 days.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Upload rejected as not a service account | You uploaded an OAuth client JSON or a `firebaseConfig` object | Download a **service account key** from Google Cloud, not a client config |
| Web badge is green, but no send works | The service account went into the **Web** slot | Upload it under **Android** — the sender reads only the Android credential |
| Sends succeed but reach the wrong project's devices | An app with a misplaced credential is falling back to the platform-wide one | Upload the correct service account under Android for that app |
| Every send fails with "no FCM credentials" | No Android credential on the app, and no platform-wide fallback either | Upload the service account under Android for that app |
| Notifications never appear, but receipts do not either | Nothing is rendering the data-only payload | Add a renderer — your own service, or the optional `openpush-android-fcm` module |
| Notifications appear twice | Two `FirebaseMessagingService` implementations in the merged manifest | Keep one; strip the other during manifest merge |
| Devices turning to `-10` in bulk | Genuine uninstalls or expired tokens reported by FCM | Expected churn; only `NOT_FOUND` and `UNREGISTERED` change status |
| A whole campaign stops with nothing marked bad | An OAuth refresh failure | Fix the service account and resend; no device state was changed |
| The message arrives with a truncated body | The rendered payload exceeded FCM's 4 KB data envelope | Shorten content, or move bulk data behind a deep link; check `op_render_truncated` |

## FAQ

**Can I keep using a legacy FCM server key?**
No. Only the HTTP v1 API with a service-account JSON is implemented.

**Why is there a Web platform slot if it does not send?**
It stores what browsers need to subscribe — the VAPID public key, your site URL, and the `firebaseConfig` object published by the web push config endpoint. The sending credential belongs under Android.

**Can I set the notification channel or accent colour from the composer?**
You can set them on the message, and they arrive in the data payload as `op_android_*` keys. Your app applies them when it renders. FCM is never asked to draw the notification.

**Does one service account cover several OpenPush apps?**
Yes, if they all live in the same Firebase project. Upload it under Android on each OpenPush app.

**How do I confirm which credential a send actually used?**
Check the app's own `platforms` readiness first. If the app has no Android credential, the send used the platform-wide fallback.

## Related

- [quickstart.md](quickstart.md)
- [sdk-android.md](sdk-android.md)
- [sdk-unity.md](sdk-unity.md)
- [web-push.md](web-push.md)
- [platform-setup-apns.md](platform-setup-apns.md)
- [sending-messages.md](sending-messages.md)
- [security-and-limits.md](security-and-limits.md)
- [../api-handbook/01-apps-keys-settings.md](../api-handbook/01-apps-keys-settings.md)
