# Quickstart: your first push


This is the shortest path from a new OpenPush account to a notification on a real phone:
create an app, upload one platform credential, initialize an SDK, register the device as a test
subscription, and send. Budget about twenty minutes, most of which is Apple or Google paperwork.

## When to use this page

Use it for your first integration, or whenever you add a new app to an existing workspace. If
you only want to understand the vocabulary first, read the [overview](overview.md).

## Prerequisites

- **An OpenPush account.** The API is served from `https://app.openpush.ai`, which is what every
  example on this page uses.
- **An app.** Creating one is a console action: `POST /v1/apps` is reserved for the platform-level
  key held by the OpenPush team, so the console is your route in. Everything after step 1 is on
  the REST API with your own app keys.
- **One set of provider credentials:**
  - Android or web: a Firebase project and a **service account JSON** with the
    Firebase Cloud Messaging API enabled.
  - iOS: an Apple Developer account and an **APNs Auth Key (.p8)**, plus its Key ID, your Team ID,
    and the app's bundle id.
- A device or simulator you can install a build onto. Push does not work on the iOS Simulator.

## Step 1 — Create the app

Create the app in the console. An app id is a slug you choose; it becomes part of every API path,
so pick something short and permanent — `acme-app` is the one used throughout this guide.

The new-app form can import your app's identity. Paste a **Google Play** or **App Store** link, or
your **website**, and OpenPush fills in the icon, tile artwork, app name and a **Brand details**
section: about the app, industry, audience and language to avoid. You can review and edit them
before you create the app. A link you set but never import is still read when the app is created.
If neither link can be read, the app is still created without brand details. Store artwork is copied
into OpenPush media. The app's icon is hidden on its All Apps tile by default; tick **Show app icon
on the All Apps tile** to show it.

Creating an app also creates its three API keys and two default segments (`Total Subscriptions`
and `Active Subscriptions`), and puts it in the workspace you are signed into. The underlying
route, `POST /v1/apps`, is one of the two platform-level routes that a customer REST key cannot
reach — see [security and limits](security-and-limits.md).

**Verify:** the app appears in the console with the id you chose.

## Step 2 — Collect the app's keys

The console shows all three on the app's settings page. Over the API, with the app's own REST key:

```bash
curl https://app.openpush.ai/v1/apps/acme-app/keys \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{
  "keys": [
    {"kind": "rest",   "name": "REST API Key",              "key": "…"},
    {"kind": "sdk",    "name": "SDK Key (public, ingest-only)", "key": "…"},
    {"kind": "legacy", "name": "Legacy API Key",            "key": "…"}
  ]
}
```

Keep them straight from the start:

- The **REST key** goes in `X-OP-API-Key` from your backend. It is full admin of this app. Never
  ship it in a binary.
- The **SDK key** goes in your app bundle. It is ingest-only by design.

Export them for the rest of this guide:

```bash
export OP_REST_KEY="…"
export OP_SDK_KEY="…"
```

## Step 3 — Upload platform credentials

**Credential upload is a console action.** There is no `/v1` route for it, because the file is a
secret that gets encrypted at rest and bound to the app. In the console, open your app and go to
**Settings → Platforms**.

### Android (and web)

Upload the **Firebase service account JSON**. OpenPush uses FCM HTTP v1 only — there is no legacy
server-key path, so a `key=AAAA…` string will not work.

> **Upload it under Android.** This is the single most common setup mistake. The sender only ever
> reads the credential stored under the `android` platform. If you upload the service account under
> **Web** only, `webpush-config` will work and the Web readiness badge will turn green, but every
> real send falls back to the platform-wide credential or fails outright. Web push is
> delivered through the same Android FCM credential.

### iOS

Upload the **.p8** file and fill in Key ID, Team ID and bundle id. OpenPush supports p8 provider
tokens only — there is no p12 or certificate path. The key must be a PKCS#8 EC P-256 private key;
anything else is rejected by name.

There is also a **Sandbox** checkbox at the app level, which chooses between Apple's production and
sandbox hosts. A device that registers with its own `sandbox` flag overrides that app-level
setting, so a TestFlight build and an App Store build can live in one OpenPush app.

**Verify:**

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

The `platforms` object reports per-app APNs, FCM and web readiness. The APNs check actually signs a
JWT with your stored key rather than just checking that a file is present, so a green iOS badge
means the credential really works.

## Step 4 — Initialize an SDK

Android is shown here because it is the fastest to get to a visible notification. The other SDKs
follow the same shape: [iOS](sdk-ios.md), [Unity](sdk-unity.md).

Add the dependencies:

```kotlin
// app/build.gradle.kts
dependencies {
    implementation("ai.openpush:openpush-android:1.1.1")
    implementation("ai.openpush:openpush-android-fcm:1.1.1")
}
```

> **The Maven artifact is not published yet.** Those coordinates are the intended ones, but until
> they are on a repository you add the library as a composite build from a source checkout. See
> [Android SDK](sdk-android.md) for the `settings.gradle.kts` substitution that works today.

Declare the messaging service in your manifest, alongside the usual Firebase setup and the
`POST_NOTIFICATIONS` permission:

```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<service
    android:name="ai.openpush.android.fcm.OpenPushMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
```

Initialize once, then hand OpenPush the FCM token:

```kotlin
class App : Application() {
    override fun onCreate() {
        super.onCreate()

        OpenPush.onLog = { line -> Log.i("OpenPush", line) }

        OpenPush.initialize(
            this,
            appId = "acme-app",
            sdkKey = BuildConfig.OPENPUSH_SDK_KEY,
            serverURL = "https://app.openpush.ai",
        )

        // onNewToken covers rotation; ask for the current token at launch.
        FirebaseMessaging.getInstance().token
            .addOnSuccessListener { token -> OpenPush.register(token) }
    }
}
```

If you would rather keep the SDK key out of source, put it in manifest meta-data as
`ai.openpush.sdk_key` and call the three-argument
`OpenPush.initialize(context, appId, serverURL)` form. The SDK refuses to configure — loudly,
through `onLog` and `onRegistrationFailed` — if the key is absent, rather than silently doing
nothing.

Ask for notification permission from an activity when the moment is right:

```kotlin
OpenPush.Notifications.requestPermission(activity, fallbackToSettings = true)
```

Then link the device to your own user id once you know who they are:

```kotlin
OpenPush.User.login("user_8412")
```

**Verify:** run the app, then list subscriptions.

```bash
curl "https://app.openpush.ai/v1/apps/acme-app/subscriptions?limit=5" \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

You should see a row with your platform, language and a truncated token. Tokens are shown as the
first 20 characters plus an ellipsis on every list route — the full token only exists in the
NDJSON export.

If nothing appears, check `OpenPush.onLog` output first: a wrong SDK key, a wrong app id or a
missing server URL all report themselves there.

## Step 5 — Register the device as a test subscription

Test subscriptions exist so you can send to yourself without touching campaign statistics. They
also **bypass the frequency cap and quiet hours on every send**, including ordinary campaigns they
happen to be in the audience of — so use them for development devices, not for a colleague's phone
you also want realistic numbers from.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/test-subscriptions \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subscription_id": "sub_4d2e8f0a1b3c", "name": "Nadia — Pixel 8"}'
```

You may pass `token` instead of `subscription_id`. Calling it again for the same device renames it
rather than creating a duplicate.

```json
{
  "app": "acme-app",
  "name": "Nadia — Pixel 8",
  "subscription_id": "sub_4d2e8f0a1b3c",
  "note": "test devices ignore the frequency cap and quiet hours on every send"
}
```

## Step 6 — Send a test push

`send-test` targets only registered test subscriptions and never creates a Sent Messages row.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/send-test \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "OpenPush is alive",
        "body": "This one came through your own stack."
      }'
```

```json
{
  "id": "msg_9c1f4a2b7de0",
  "is_test": true,
  "devices": 1,
  "funnel": {"Audience": 1, "Capped": 0, "Held": 0, "Remaining": 1,
             "Sent": 1, "Failed": 0, "Retryable": 0, "Delivered": 1},
  "note": "test sends do not appear in Sent Messages"
}
```

If you get `404 no test subscriptions — add one first`, go back to step 5.

## Step 7 — Send for real

The main send endpoint is `POST /v1/apps/{app_id}/messages`. With no `include_segments`, it targets
every sendable subscription in the app.

```bash
curl -X POST https://app.openpush.ai/v1/apps/acme-app/messages \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Launch announcement",
        "title": "Season 4 is live",
        "body": "Three new maps and a ranked reset. Jump in.",
        "deep_link": "acme://season/4"
      }'
```

```json
{
  "id": "msg_2f7ba0c41d93",
  "title": "Season 4 is live",
  "status": "Delivered",
  "sent": 1,
  "provider_accepted": 1,
  "failed": 0,
  "funnel": {"Audience": 1, "Capped": 0, "Held": 0, "Remaining": 1,
             "Sent": 1, "Failed": 0, "Retryable": 0, "Delivered": 1},
  "note": "provider_accepted = FCM took it; device receipts arrive via /v1/ingest",
  "report": "/v1/apps/acme-app/messages/msg_2f7ba0c41d93"
}
```

**There is no idempotency on this route.** If the call times out, check
`GET /v1/apps/acme-app/messages` before retrying — a blind retry sends the campaign twice.

## Step 8 — Read the report

```bash
curl https://app.openpush.ai/v1/apps/acme-app/messages/msg_2f7ba0c41d93 \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

`Provider Accepted` fills in immediately. `Device Received`, `Confirmed Receipt` and `Clicked`
arrive only as the SDK posts them back to `/v1/ingest`, which the Android and iOS SDKs do for you
once integrated. If those three stay at zero while `Provider Accepted` climbs, the message reached
the provider and the receipt path is not wired up.

## Common errors

| Response | Cause | Fix |
|---|---|---|
| `401 bad X-OP-API-Key (org routes need the global key)` | Called `POST /v1/apps` with an app REST key | App creation is not on the customer API — create the app in the console |
| `404 no matching subscriptions — did the app register?` | Immediate send with an empty audience | Confirm a device registered in step 4 |
| `400 need title+body or a known template` | Neither the body nor a resolved template supplied both fields | Supply `title` and `body`, or a valid `template` |
| `404 no test subscriptions — add one first` | `send-test` with no test devices registered | Complete step 5 |
| `400 data is not provider-safe JSON: …` | `data` contains something FCM's string-only map cannot carry | Keep `data` to JSON-safe values; nested objects are serialized for you |
| Sends fail with "no FCM credentials" while the Web badge is green | Service account uploaded under Web only | Re-upload it under **Android** |

## Limits

- App creation and app listing are platform-level routes, not customer ones; there is no
  org-scoped variant. Create apps in the console.
- Platform credential upload and media upload are console-only. Everything else in this guide is
  on the REST API.
- The default request body ceiling is 8 MB (150 MB for import uploads).
- The send route is not rate-limited, and offers no idempotency key.

## FAQ

**Can I skip the SDK and register a device myself?**
Yes — `POST /v1/apps/{app_id}/subscriptions` with the SDK key takes a raw token. That is how the
web test client works. You lose the receipt ladder unless you post those events too.

**Do I need both an APNs and an FCM credential?**
Only for the platforms you ship to. iOS devices with a real APNs token go to Apple; everything
else goes through FCM. An iOS device that is still carrying an FCM registration token — common
right after a OneSignal migration — is routed to FCM automatically.

**Why did my first message report `Delivered` but nothing appeared on the phone?**
`Delivered` here means the provider accepted it. Check that notification permission was granted,
that the app is not in a quiet-hours hold, and that your Android manifest declares the messaging
service.

**Can I send to one specific person without a segment?**
Yes. Use the `target` object with `external_id`, `token`, `subscription_id`, or an
`{"label": …, "id": …}` alias. See [sending messages](sending-messages.md).

**How do I roll a leaked key?**
`POST /v1/apps/{app_id}/keys/{kind}/rotate`. It replaces the key in place with no grace window, so
deploy the new SDK key before rotating that one.

## Related

- [Overview](overview.md)
- [Android SDK](sdk-android.md) · [iOS SDK](sdk-ios.md) · [Unity SDK](sdk-unity.md)
- [APNs setup](platform-setup-apns.md) · [FCM setup](platform-setup-fcm.md)
- [Sending messages](sending-messages.md)
- [Messages API](../api-handbook/02-messages.md)
