# Perchpush docs for coding agents

> Perchpush (https://perchpush.com) sends push notifications (Android, iOS, web), in-app messages, an inbox, email and SMS from one API, through the customer's own Firebase, APNs, VAPID, SMTP and SMS credentials. This file covers every SDK and the REST API. Method names are verified against the SDK sources; do not use names that are not listed here. Human docs: https://perchpush.com/docs.

For a specific app, the dashboard generates a brief with its client key, platforms and connection status filled in (Setup guide → "Set up with your AI coding agent"), also available from `GET /api/v1/agent-brief` with header `X-App-Key: pk_…`. Prefer it over this file when you have it.

## Concepts

- **App**: one product (Android, iOS and web share it). Each app has a public **client key** (`pk_…`, used by SDKs in apps and websites) and **server keys** (`sk_…`, backend only).
- **Push providers**: the customer uploads their Firebase service-account JSON (Android, and iOS via FCM), optionally an APNs .p8 key (direct iOS), and generates VAPID keys for web push in the dashboard (Settings → Push providers). Agents cannot do this; tell the human.
- **Devices** register with a token (FCM token, raw APNs token or a web push subscription). **People** (contacts) are linked to devices by `identify(externalUserId)`, signed with an HMAC when identity verification is on.
- **Campaigns** target `ALL`, `SEGMENTS`, `USERS` (external user ids), `DEVICES` or `FILTERS`, on channels `PUSH`, `INBOX`, `EMAIL`, `SMS` and `LIVE` (live in-app).

## Keys and secrets

- Client key `pk_…`: public, goes in app and website code.
- Server key `sk_…`: backend only, env `PERCHPUSH_SECRET_KEY`. Create it in the dashboard under API keys, with scopes `send`, `read`, `users`.
- Identity secret `ids_…`: backend only, env `PERCHPUSH_IDENTITY_SECRET`. Settings → Keys & identity.
- Never commit `sk_` or `ids_` values, never put them in client code, and never compute identity signatures on the client.

## Flutter

Package `perchpush_flutter` v1.3.0: pure Dart, depends only on `http` and `shared_preferences`, and attaches to the app's own `firebase_messaging` (it is not a replacement for FCM). Import `package:perchpush_flutter/perchpush_flutter.dart`. Everything is a static member of `Perchpush`. Requires Dart 3.3+ and Flutter 3.19+.

### 1. Firebase (skip what the project already has)

```bash
# Connect the app to your Firebase project (once)
flutter pub add firebase_core firebase_messaging url_launcher
dart pub global activate flutterfire_cli
flutterfire configure
```

`flutterfire configure` is interactive: it asks for the Firebase project and platforms, then writes `lib/firebase_options.dart`, `android/app/google-services.json` and `ios/Runner/GoogleService-Info.plist`. If you can't answer its prompts, ask the human to run it. Use the same Firebase project whose service account the human uploads to Perchpush.

### 2. Add the SDK

In `pubspec.yaml` under `dependencies:` (it is served from a git URL, not pub.dev):

```yaml
dependencies:
  perchpush_flutter:
    git:
      url: https://perchpush.com/sdk/git/perchpush_flutter.git
      ref: v1.3.0
```

Then run `flutter pub get`.

### 3. Initialise in main()

```dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:perchpush_flutter/perchpush_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import 'firebase_options.dart';

final navigatorKey = GlobalKey<NavigatorState>();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  Perchpush.configure(appKey: 'pk_your_app_key');
  await Perchpush.attachFirebase(
    FirebaseMessaging.instance,
    onMessageOpenedApp: FirebaseMessaging.onMessageOpenedApp,
    onMessage: FirebaseMessaging.onMessage,
    onUrl: (url) => launchUrl(Uri.parse(url)),
  );
  Perchpush.enableInAppMessages(navigatorKey: navigatorKey);

  Perchpush.onNotificationOpened.listen((n) {
    if (n.targetRoute != null) navigatorKey.currentState?.pushNamed(n.targetRoute!);
  });

  runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomePage()));
}
```

Adapt it, do not paste it over the existing file:
- Keep everything the app already does in `main()`. Add the Perchpush lines after `Firebase.initializeApp(...)` and call `Perchpush.configure` exactly once.
- `attachFirebase` asks for notification permission, registers the FCM token (and refreshes), reports taps from background and cold start, and shows foreground pushes on iOS. It adds listeners to `FirebaseMessaging.onMessage` and `onMessageOpenedApp`; those are broadcast streams, so existing listeners keep working.
- Routing: `n.targetRoute` is the tapped button's route, else the notification's route, exactly as typed in the dashboard (for example `/lessons/42`). Map it to the app's router. With go_router, give the same `navigatorKey` to `GoRouter(navigatorKey: navigatorKey, ...)` and call `router.go(route)` / `router.push(route)` instead of `pushNamed`.
- `onUrl` receives http(s) URLs from notifications; the sample opens them with `url_launcher`.
- `enableInAppMessages(navigatorKey: ...)` shows in-app messages as routes on that navigator, so the key must be the one on the `MaterialApp` / router.

### 4. Notification permission

```dart
// attachFirebase asks for permission by default. To ask later instead:
await Perchpush.attachFirebase(FirebaseMessaging.instance, requestPermission: false, /* … */);
// …then, at a good moment:
await FirebaseMessaging.instance.requestPermission();

// In debug builds, print a setup checklist:
if (kDebugMode) await Perchpush.runSetupTest();
```

`attachFirebase` calls `FirebaseMessaging.requestPermission()`, which shows the Android 13+ runtime prompt and the iOS prompt. To ask later (after onboarding, for example), pass `requestPermission: false` and call `FirebaseMessaging.instance.requestPermission()` at that moment. `kDebugMode` comes from `package:flutter/foundation.dart`.

### 5. Delivery receipts in the background (recommended)

```dart
// Top-level function (not inside a class):
@pragma('vm:entry-point')
Future<void> perchpushBackground(RemoteMessage message) => Perchpush.reportReceived(message.data);

// In main(), before runApp:
FirebaseMessaging.onBackgroundMessage(perchpushBackground);
```

Only one background handler can be registered. If the app already has one, call `Perchpush.reportReceived(message.data)` inside it instead. `reportReceived` works without `configure` (the receipt URL travels in the push).

### 6. Users, events and sign-out

```dart
// After sign-in AND when a saved session is restored on launch:
await Perchpush.identify(user.id, signature: signatureFromYourBackend, attributes: {'plan': 'pro'}, email: user.email);

// Sign-out (detaches the device from the person; it still gets anonymous pushes):
await Perchpush.logout();

// Events for segments, journeys and in-app triggers; purchases for revenue:
await Perchpush.track('lesson_completed', properties: {'unit': 3});
await Perchpush.trackPurchase(9.99, currency: 'USD', productId: 'premium_monthly');
await Perchpush.setTags({'theme': 'dark'});
```

`identify` is safe to call before the token exists: the user id is stored and linked once the device registers. Use the app's stable user id (database id), never an email that can change. See **Identity verification** below for `signature`.

### 7. In-app messages and inbox

```dart
// In-app messages and inbox
Perchpush.setTrigger('cart_items', 3);         // local only, used by in-app rules
final inbox = await Perchpush.inbox();
await Perchpush.inboxMarkRead(inbox.map((m) => m.id).toList());

// Attribution links (from your deep-link handler)
await Perchpush.handleDeepLink(uri);
```

`Perchpush.inbox()` returns `List<PerchpushInboxMessage>` with `id`, `title`, `body`, `imageUrl`, `url`, `route`, `read`, `createdAt`. Build the inbox screen with the app's own widgets. `handleDeepLink(uri)` is for Perchpush tracking links; call it from the existing deep-link handler (app_links / uni_links) if the app has one, otherwise skip it.

### 8. Foreground notifications on Android (optional)

Android does not display FCM notifications while the app is in the foreground. If the app already uses `flutter_local_notifications`, or the human wants foreground banners, add:

```dart
// Android doesn't show pushes while the app is open. Show them yourself:
Perchpush.onNotificationReceived.listen((n) {
  localNotifications.show(n.hashCode, n.title, n.body, details, payload: jsonEncode(n.data));
});
// …and on tap of that local notification:
Perchpush.trackNotificationOpen(jsonDecode(payload!), buttonId: response.actionId);
```

### 9. Native project setup

Android:
- `android/app/src/main/AndroidManifest.xml` must declare `<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>` (Android 13+). Add it if it is missing; a duplicate merged from a library is harmless.
- `compileSdk`/`targetSdk` 33 or higher so the runtime prompt can appear.
- Optional small icon (white on transparent) for notifications:

```xml
<!-- android/app/src/main/AndroidManifest.xml, inside <application> (optional) -->
<meta-data
  android:name="com.google.firebase.messaging.default_notification_icon"
  android:resource="@drawable/ic_notification" />
```

iOS:

```text
iOS checklist:
1. Xcode → Runner → Signing & Capabilities → + Push Notifications
2. + Background Modes → tick "Remote notifications"
3. Apple Developer → Keys → create an APNs key (.p8)
4. Upload it to Firebase (Project settings → Cloud Messaging) — and optionally
   to Perchpush (Settings → Push providers) for direct APNs delivery
   (then attachFirebase(…, useApnsToken: true))
```

- Steps 1 and 2 are Xcode settings: add them to `ios/Runner/Runner.entitlements` (`aps-environment`) and `Info.plist` (`UIBackgroundModes` → `remote-notification`) if you can edit those files, otherwise list them for the human.
- Test on a real iPhone. Push permission and APNs tokens are unreliable on the simulator.

### 10. Verify (Flutter)

Add `if (kDebugMode) await Perchpush.runSetupTest();` after `attachFirebase`, run `flutter run` on a real device (or an Android emulator image with Google Play), allow notifications, and look for:

```text
[Perchpush] setup test for <your app> (SDK 1.3.0):
[Perchpush]   ✓ App key belongs to "<your app>"
[Perchpush]   ✓ Firebase key uploaded
[Perchpush]   ✓ Device registered (ANDROID, FCM)
[Perchpush]   ✓ Notification permission granted
```

A `✗` line names what is missing; `–` lines are optional. For more detail pass `debug: true` to `Perchpush.configure`, which logs failed API calls as `[Perchpush] /api/v1/... → <status> <error>`. `Perchpush.token` is the device token for the API checks in **Verify**.

## React Native

Package `@perchpush/react-native`: TypeScript only, no native module. It works beside `@react-native-firebase/messaging` (or `expo-notifications`), which owns the tokens and taps.

### 1. Install and initialise

```ts
npm i https://perchpush.com/sdk/npm/perchpush-react-native-1.0.0.tgz @react-native-firebase/app @react-native-firebase/messaging @react-native-async-storage/async-storage

import AsyncStorage from '@react-native-async-storage/async-storage';
import messaging from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
import Perchpush from '@perchpush/react-native';

Perchpush.configure({ appKey: 'pk_your_app_key', platform: Platform.OS, storage: AsyncStorage });
await messaging().requestPermission();
await Perchpush.registerPushToken(await messaging().getToken());
messaging().onTokenRefresh((t) => Perchpush.registerPushToken(t));
messaging().onNotificationOpenedApp((m) => Perchpush.trackNotificationOpen(m.data));
```

- The first line is the shell command (the package is served as a tarball, not from the npm registry yet). Then `cd ios && pod install`.
- If `@react-native-firebase/app` is new to the project, follow its native setup (rnfirebase.io): `android/app/google-services.json` + the `com.google.gms.google-services` Gradle plugin, and `ios/GoogleService-Info.plist`. Expo: React Native Firebase needs a development build, not Expo Go.
- The sample uses top-level `await`: run it once at startup inside an async function (for example a `useEffect` in the root component).

### 2. Permission, cold start, background receipts

```ts
import { PermissionsAndroid, Platform } from 'react-native';

// Android 13+: messaging().requestPermission() does not show the Android prompt.
if (Platform.OS === 'android' && Number(Platform.Version) >= 33) {
  await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
}

// App opened from a notification while it was quit:
const initial = await messaging().getInitialNotification();
if (initial) Perchpush.trackNotificationOpen(initial.data);

// Route taps (from background, quit, and attribution links):
Perchpush.onNotificationOpened((n) => { if (n.route) navigation.navigate(n.route); });

// index.js, outside any component:
messaging().setBackgroundMessageHandler(async (m) => { await Perchpush.reportReceived(m.data); });
```

Add `<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>` to `AndroidManifest.xml` if missing. `n.route` is the string typed in the dashboard; map it to the app's navigator (React Navigation `navigate`, Expo Router `router.push`). If the app already has a background handler, add the `reportReceived` call to it.

### 3. Users, events, in-app, inbox

```ts
await Perchpush.identify(user.id, { signature, attributes: { plan: 'pro' }, email: user.email });
await Perchpush.logout(); // on sign-out
await Perchpush.track('lesson_completed', { unit: 3 });
await Perchpush.trackPurchase(9.99, { currency: 'USD', productId: 'premium_monthly' });

const [message] = await Perchpush.inAppMessages(); // highest priority first; render with your own components
if (message) Perchpush.reportInApp(message.id, 'impression'); // then 'click' (with the button id) or 'dismiss'
const inbox = await Perchpush.inbox();
await Perchpush.inboxMarkRead(['<message id>']);
```

React Native does not draw in-app messages; it returns them (`layout`, `title`, `body`, `imageUrl`, `buttons`, `style`) for your own UI.

### 4. Verify (React Native)

Run on a device (`npx react-native run-android` / `run-ios`, or `npx expo run:android`), allow notifications, then `console.log(JSON.stringify(await Perchpush.runSetupTest(), null, 2))`. Expect `"ready": true` and a check with `"message": "Device registered (ANDROID, FCM)"`. `runSetupTest` returns the checklist; it does not print it. `Perchpush.token` is the device token.

## iOS (Swift)

Swift package `Perchpush` (zero dependencies, iOS 13+). Xcode → File → Add Package Dependencies → `https://perchpush.com/sdk/git/perchpush-ios.git`, "Up to Next Major" from 1.0.0, product `Perchpush`. In a `Package.swift`: `.package(url: "https://perchpush.com/sdk/git/perchpush-ios.git", from: "1.0.0")`. Editing `project.pbxproj` by hand is error-prone; if you cannot add the package with a tool, ask the human to add it in Xcode.

### 1. The short version

```swift
// Xcode → File → Add Package Dependencies → https://perchpush.com/sdk/git/perchpush-ios.git (from 1.0.0), then:
import Perchpush

// AppDelegate.application(_:didFinishLaunchingWithOptions:)
Perchpush.configure(appKey: "pk_your_app_key")
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
application.registerForRemoteNotifications()

// didRegisterForRemoteNotificationsWithDeviceToken:
Perchpush.registerDeviceToken(deviceToken)   // direct APNs (or registerFCMToken)

// userNotificationCenter(_:didReceive:withCompletionHandler:)
Perchpush.handleNotificationResponse(response)
```

### 2. Complete AppDelegate (UIKit, or SwiftUI via `@UIApplicationDelegateAdaptor`)

```swift
// Merge into the existing AppDelegate; do not create a second one.
import UIKit
import UserNotifications
import Perchpush

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
    Perchpush.configure(appKey: "pk_your_app_key")
    UNUserNotificationCenter.current().delegate = self // without this, taps are never reported
    Perchpush.onNotificationOpened = { n in
      // n.route / n.url: route inside the app
    }
    Perchpush.requestPermission { granted in
      if granted { UIApplication.shared.registerForRemoteNotifications() }
    }
    return true
  }

  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Perchpush.registerDeviceToken(deviceToken) // direct APNs: needs the APNs key in Perchpush
  }

  func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
    Perchpush.reportReceived(userInfo: notification.request.content.userInfo)
    completionHandler([.banner, .sound, .badge])
  }

  func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    Perchpush.handleNotificationResponse(response)
    completionHandler()
  }
}

// SwiftUI: @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
```

- Token path: `registerDeviceToken` sends the raw APNs token, which needs an APNs key uploaded to Perchpush. If the app already uses Firebase Messaging on iOS, call `Perchpush.registerFCMToken(fcmToken)` from `messaging(_:didReceiveRegistrationToken:)` instead and keep the existing Firebase setup. Use one path, not both.
- If another `UNUserNotificationCenterDelegate` exists, add the two Perchpush calls to it instead of replacing it.
- Users and events: `Perchpush.identify(user.id, signature: sig, attributes: ["plan": "pro"])` after sign-in and on session restore, `Perchpush.logout()` on sign-out, `Perchpush.track("lesson_completed", properties: ["unit": 3])`, `Perchpush.trackPurchase(9.99, currency: "USD", productId: "premium_monthly")`, `Perchpush.handleDeepLink(url)` from the universal-link handler.
- The iOS SDK has no inbox or in-app UI. If you need the inbox, call `GET <base>/api/v1/inbox?token=<Perchpush.token>` with header `X-App-Key: <client key>`.

### 3. Capabilities and gotchas

- Signing & Capabilities → **Push Notifications** (adds `aps-environment`), and **Background Modes → Remote notifications** for receipts and silent pushes.
- Xcode debug builds get **sandbox** APNs tokens; TestFlight and App Store builds get production ones. The APNs key's environment in Perchpush must match, or pushes are rejected.
- Test on a real device.

### 4. Verify (iOS)

Configure with `debug: true` to log failed API calls as `[Perchpush] /api/v1/... → <status> ...`. Run on a device, allow notifications, print `Perchpush.token`, and check it with the `/api/v1/sdk/check` call in **Verify**.

## Android (Kotlin)

Perchpush for Android is a zero-dependency Kotlin library (`com.neptechpal.perchpush.Perchpush`, minSdk 21) that works with the app's existing Firebase Cloud Messaging.

> Distribution status: **preview, not on a public Maven repository yet.** The planned coordinate is `com.github.Neptechpal-pvt-ltd:perchpush-android:1.0.0` (JitPack). If Gradle cannot resolve it, do not substitute another package: ask the human for the SDK module and include it with `implementation project(":perchpush")`, or use the REST fallback at the end of this section.

### 1. Firebase

Add `google-services.json` to `app/`, the `com.google.gms.google-services` Gradle plugin, and `com.google.firebase:firebase-messaging` (via the Firebase BoM) if the app does not have them.

### 2. The short version

```kotlin
// Application.onCreate
Perchpush.init(this, "pk_your_app_key")

// Your FirebaseMessagingService
override fun onNewToken(token: String) = Perchpush.registerToken(token)

// Launcher Activity.onCreate — counts opens
Perchpush.handleOpen(intent.extras)

// Android 13+: ask for POST_NOTIFICATIONS at a good moment
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
```

### 3. Complete wiring

```kotlin
import com.neptechpal.perchpush.Perchpush

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Perchpush.init(this, "pk_your_app_key")
        Perchpush.onNotificationOpened = { n -> n.route?.let { /* open this screen */ } }
    }
}

class PushService : FirebaseMessagingService() {
    override fun onNewToken(token: String) = Perchpush.registerToken(token)
    override fun onMessageReceived(message: RemoteMessage) {
        Perchpush.reportReceived(message.data) // confirmed delivery
    }
}

// Launcher Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    Perchpush.handleOpen(intent.extras)
    // onNewToken only fires for new tokens, so also register the current one on start:
    FirebaseMessaging.getInstance().token.addOnSuccessListener { Perchpush.registerToken(it) }
    if (Build.VERSION.SDK_INT >= 33) requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    Perchpush.handleOpen(intent.extras)
}
```

- Manifest: register the Application class (`android:name`), declare the service with an intent filter for `com.google.firebase.MESSAGING_EVENT`, and add `<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>`. If the app already has a `FirebaseMessagingService`, add the two Perchpush calls to it; Android delivers to only one.
- Users and events: `Perchpush.identify(user.id, signature = sig, attributes = mapOf("plan" to "pro"))`, `Perchpush.logout()`, `Perchpush.track("lesson_completed", mapOf("unit" to 3))`, `Perchpush.trackPurchase(9.99, "USD", "premium_monthly")`, `Perchpush.handleDeepLink(uri)`.
- No inbox or in-app UI in the Android SDK. For the inbox call `GET <base>/api/v1/inbox?token=<Perchpush.token>` with header `X-App-Key`.
- Verify: `Perchpush.init(this, key, debug = true)` logs failed calls to Logcat with tag `Perchpush`. Log `Perchpush.token` and check it with the `/api/v1/sdk/check` call in **Verify**.

### REST fallback (if the library is unavailable)

Register the FCM token yourself on start and in `onNewToken`: `POST https://perchpush.com/api/v1/devices` with headers `X-App-Key: pk_your_app_key` and `Content-Type: application/json`, body `{"token":"<fcm token>","platform":"ANDROID","channel":"FCM","notificationsEnabled":true}`. Identify with `POST /api/v1/identify` (fields `token`, `externalUserId`, `signature`); record taps with `POST /api/v1/events/open` (fields `token`, `campaignId` = the push's `perchpush_campaign_id` data value).

## Website (web push)

No npm package: a script tag defines `window.Perchpush`, plus a service worker file you host.

### 1. Host the service worker at the site root

```bash
curl -fsSL https://perchpush.com/sdk/perchpush-sw.js -o public/perchpush-sw.js
```

- It must be served from the same origin at `/perchpush-sw.js` so its scope covers the whole site. `public/` is right for Next.js, Vite, CRA, Nuxt and SvelteKit (`static/`); for other stacks use whatever folder is served at the web root.
- If the site already registers a service worker at scope `/` (a PWA, Workbox, next-pwa), do not register a second one. Add `importScripts('https://perchpush.com/sdk/perchpush-sw.js');` at the top of the existing worker and pass `serviceWorker: '/<existing-worker>.js'` to `Perchpush.init`. Firebase's `firebase-messaging-sw.js` uses its own scope and can stay.

### 2. Load and initialise the SDK on every page

```html
<!-- 1. Copy https://perchpush.com/sdk/perchpush-sw.js to your site root as /perchpush-sw.js -->
<!-- 2. Load the SDK -->
<script src="https://perchpush.com/sdk/perchpush.js"></script>
<script>
  Perchpush.init({ appKey: 'pk_your_app_key' });
  document.querySelector('#notify-me').onclick = () => Perchpush.requestPermission();
  // Tapping a notification opens its URL (or route on this site): the service worker handles it.
</script>
```

- `#notify-me` is a placeholder: wire `Perchpush.requestPermission()` to a real button's click handler. It returns `Promise<boolean>`. Safari only shows the prompt from a user gesture, so never call it on page load.
- `init` options (all verified): `appKey` (required, `pk_…`), `baseUrl`, `serviceWorker` (default `/perchpush-sw.js`), `debug`, `inApp` (`false` stops the SDK rendering in-app messages), `prompt` (`false` disables the subscribe prompt and bell configured in the dashboard).
- Single-page apps: load the script once in the root HTML/layout and call `init` once. Next.js app router: a client component in the root layout with `<Script src="…/sdk/perchpush.js" strategy="afterInteractive" onLoad={() => window.Perchpush.init({ appKey: '…' })} />` (`onLoad` only works in client components).
- TypeScript: declare it, for example `declare global { interface Window { Perchpush: any } }`.
- If the site sends a Content-Security-Policy, allow the script origin in `script-src` and the API origin in `connect-src` (fetch and EventSource).

### 3. Users, events, inbox

```js
Perchpush.identify('user-42', { signature, attributes: { plan: 'pro' } });
Perchpush.track('signup');
Perchpush.trackPurchase(9.99, { currency: 'USD' });
Perchpush.inbox().then(render);
Perchpush.setTrigger('pricing_visits', 3);
```

`identify(userId, { signature, attributes, email, phone })` after sign-in and on session restore; `Perchpush.logout()` on sign-out. `Perchpush.inbox()` resolves to an array of `{ id, title, body, imageUrl, url, route, data, readAt, createdAt, campaignId }`; render it with the site's own components and call `Perchpush.inboxMarkRead(ids)`.

### 4. Taps and in-app messages

- Taps need no code: the service worker opens the notification's `url`, else its `route` resolved against the site's origin (so routes must be site paths like `/pricing`), else `/`. The SDK records the open on that page.
- `Perchpush.on('opened', fn)` receives `{ campaignId, variant, buttonId }` (no route: navigation already happened). It fires during `init`, so register it before calling `init`. `Perchpush.on('message', fn)` receives live in-app messages.
- In-app messages (banner, modal, full screen) render automatically unless `inApp: false`. `Perchpush.setTrigger(key, value)` feeds their local trigger rules; `pauseInAppMessages()` / `resumeInAppMessages()` hold them during checkout or video.

### 5. Web push gotchas

- HTTPS is required (`http://localhost` is allowed for development).
- iPhone and iPad: Safari supports web push only for sites added to the Home Screen (iOS 16.4+) with a web app manifest (`"display": "standalone"`).
- Once a visitor blocks notifications, code cannot ask again; they must change the site setting.
- `Perchpush.isSupported()` is false in browsers without service workers or the Push API; hide the subscribe button there.

### 6. Verify (web)

Open the site over HTTPS or localhost, click the subscribe button, allow notifications, then in the browser console run `await Perchpush.runSetupTest()`. It prints lines like `[Perchpush] ✓ Device registered (WEB, WEBPUSH)` and `[Perchpush] ✓ Notification permission granted`. DevTools → Application → Service workers should show `/perchpush-sw.js` activated. `Perchpush.token` is the subscription JSON (URL-encode it for the API checks).

## Identity verification (backend)

Apps can require a signature (Settings → Keys & identity → "Require signed identify()"). Unsigned calls are then rejected with 401; without a signature, `email` and `phone` are ignored.

The signature is `hex(HMAC-SHA256(identitySecret, userId))`, computed **on the app's backend** with the identity secret (`ids_…`, from env `PERCHPUSH_IDENTITY_SECRET`) used as the raw UTF-8 key, over exactly the user id string the app passes to `identify()`. Return it from the existing login / session endpoint (for example a `perchpushSignature` field) or a small authenticated endpoint, and pass it as `signature`.

```ts
// Your backend: sign the user id for identify()
import { signIdentity } from '@perchpush/node';
const signature = signIdentity(process.env.PERCHPUSH_IDENTITY_SECRET, user.id);

// any language: hex(HMAC_SHA256(identitySecret, userId))
```

```text
Node (no dependency):  createHmac('sha256', process.env.PERCHPUSH_IDENTITY_SECRET).update(userId).digest('hex')
Python:   hmac.new(os.environ["PERCHPUSH_IDENTITY_SECRET"].encode(), user_id.encode(), hashlib.sha256).hexdigest()
PHP:      hash_hmac('sha256', $userId, getenv('PERCHPUSH_IDENTITY_SECRET'))
Ruby:     OpenSSL::HMAC.hexdigest('SHA256', ENV['PERCHPUSH_IDENTITY_SECRET'], user_id)
Go:       h := hmac.New(sha256.New, []byte(os.Getenv("PERCHPUSH_IDENTITY_SECRET"))); h.Write([]byte(userID)); hex.EncodeToString(h.Sum(nil))
```

Never compute the signature in the app or website: the secret would ship to every user. If the project has no backend of its own, say so to the human instead of signing on the client.

## Sending from a backend (optional)

Only if the codebase has a server that should trigger notifications (order shipped, new message). Use the server key from env `PERCHPUSH_SECRET_KEY`. REST base: `https://perchpush.com/api/v1`, header `Authorization: Bearer sk_…`.

```bash
curl -X POST https://perchpush.com/api/v1/notifications \
  -H "Authorization: Bearer $PERCHPUSH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234-shipped" \
  -d '{
    "title": "Your order shipped 🚚",
    "body": "Hi {{ first_name | default: \"there\" }}, tap to track it.",
    "route": "/orders/1234",
    "audience": "USERS",
    "externalUserIds": ["user-42"],
    "transactional": true
  }'
```

Node: `npm i https://perchpush.com/sdk/npm/perchpush-node-1.0.0.tgz` (package `@perchpush/node`).

```ts
// npm i https://perchpush.com/sdk/npm/perchpush-node-1.0.0.tgz
import { Perchpush } from '@perchpush/node';
const perchpush = new Perchpush(process.env.PERCHPUSH_SECRET_KEY);

await perchpush.send({
  title: 'New lesson is live',
  body: 'Chapter 4 just dropped.',
  channels: ['PUSH', 'INBOX'],
  audience: 'FILTERS',
  filters: { attributes: [{ key: 'plan', op: 'eq', value: 'pro' }], lastSeenWithinDays: 30 },
  localSendAt: '2026-11-01T09:00', // 9am in every user's timezone
});
```

Methods on `new Perchpush(secretKey, { baseUrl? })`: `send(notification, { idempotencyKey? })`, `sendToUsers(externalUserIds, notification, opts?)`, `getNotification(id)`, `listNotifications(limit?)`, `cancelNotification(id)`, `upsertUser(user)`, `getUser(externalUserId)`, `setUnsubscribed(externalUserId, bool)`, `deleteUser(externalUserId)`, `trackEvent(externalUserId, event, { properties?, revenue?, currency? })`, `importUsers(users)`, `deliveryLog({ deviceId?, externalUserId?, campaignId?, limit? })`. Helpers: `signIdentity(secret, userId)`, `verifyWebhook(rawBody, header, secret)`.

## REST API

Base URL `https://perchpush.com/api/v1`. Responses are `{ "data": … }`; errors are `{ "error": "…" }` with 400, 401, 402 (plan limit), 403 (missing scope or IP), 404, 409 or 429.

### Server endpoints (`Authorization: Bearer sk_…`)

| Endpoint | What it does |
| --- | --- |
| `POST /notifications` | Send now or schedule. Optional `Idempotency-Key` header. Scope `send`. 402 when the month's sends are used up. |
| `GET /notifications?limit=` | Latest notifications with stats. Scope `read`. |
| `GET /notifications/:id` | One notification with stats. Scope `read`. |
| `DELETE /notifications/:id` | Cancel a scheduled, paused or in-flight notification. |
| `POST /notifications/:id/pause · /resume` | Pause or resume a sending notification. |
| `POST /users` | Create or update a person: `externalUserId`, `email`, `phone`, `attributes` (null removes), `unsubscribed`, `emailOptOut`, `smsOptOut`. Scope `users`. |
| `GET /users/:id` | A person and their devices. Scope `users`. |
| `PATCH /users/:id` | Update; `{ "unsubscribed": false }` lifts a durable opt-out. |
| `DELETE /users/:id` | Erase the person, devices, events, logs and inbox. |
| `POST /users/:id/events` | Server-side event: `{ event, properties?, revenue?, currency? }`. |
| `POST /import` | `{ users: [...] }`, up to 10,000 per call. |
| `GET /messages?deviceId=|externalUserId=|campaignId=` | Delivery log with raw provider responses. Scope `read`. |

### Notification body (POST /notifications)

`title` and `body` (required), `channels` (default `["PUSH"]`), `route`, `url`, `imageUrl`, `data` (string map), `buttons` (up to 3: `{ id, title, url?, route? }`), `audience` (`ALL` default, `SEGMENTS` + `segmentIds`, `USERS` + `externalUserIds`, `DEVICES` + `deviceIds`, `FILTERS` + `filters`), `excludeSegmentIds`, `transactional` (skips quiet hours and frequency caps), `respectQuietHours`, `scheduledAt` (ISO), `localSendAt` (`"2026-11-01T09:00"` in each recipient's time zone), `intelligentDelivery`, `throttlePerMin`, `topic`, `translations` (`{ "es": { title, body } }`), `variants` + `abTestPercent`, `controlPercent` + `goalEvent`, `recurrence` (`DAILY`|`WEEKLY`), `silent`, `ttlSeconds`, `badge`, `emailSubject`, `emailHtml`, `smsText`, `templateId` + `templateData`, `platformOptions` (`ios`, `android`, `web`). Filters include `platforms`, `loggedIn`, `lastSeenWithinDays`, `notSeenForDays`, `appVersions`, `locales`, `countries`, `testDevicesOnly`, `tags`, `attributes`.

### SDK endpoints (`X-App-Key: pk_…`, used by the SDKs; call directly only on platforms without an SDK)

| Endpoint | What it does |
| --- | --- |
| `POST /devices` | Register or refresh: `token`, `platform` (ANDROID|IOS|WEB), `channel` (FCM|APNS|WEBPUSH), facts, `tags`, `previousToken`, `clickId`. |
| `POST /identify` | `token`, `externalUserId`, `signature`, `attributes`, `email`, `phone`. |
| `POST /logout · /unsubscribe` | Detach the person · durable opt-out. |
| `POST /track · /purchase` | Events (`token`, `event`, `properties`) and revenue (`token`, `amount`, `currency`, `productId`). |
| `POST /events/open` | `token`, `campaignId`, `variant?`, `button?`. |
| `GET /inbox?token= · POST /inbox/read` | Inbox messages · mark read (`token`, `ids`). |
| `GET /inapp?token= · POST /inapp/event` | In-app rules · `impression` / `click` / `dismiss`. |
| `GET /topics?token= · POST /topics` | Preference-center topics · `token`, `topic`, `subscribed`. |
| `GET /live?key=&token=` | Server-sent events for live in-app messages. |
| `POST /deeplink` | Attribute an open to a tracking-link click. |
| `GET /sdk/check?token=` | Setup checklist: `{ app, checks: [{ id, ok, optional, message }], ready }`. |
| `GET /sdk/channels` | Android notification channels defined in the dashboard. |

## Verify (testing an integration)

- `curl -sG https://perchpush.com/api/v1/sdk/check -H "X-App-Key: pk_…" --data-urlencode "token=<DEVICE_TOKEN>"` returns the setup checklist for a device (public key only).
- The dashboard's Subscriptions page lists registered devices; mark one as a test device, then send a test from the campaign composer, or `POST /notifications` with `"audience":"FILTERS","filters":{"testDevicesOnly":true},"transactional":true`.

## Do not

- Do not commit, log or hard-code a server key (`sk_…`) or the identity secret (`ids_…`). Read them from env vars on the backend only, with the env file listed in `.gitignore`. The client key (`pk_…`) is public and fine in code.
- Do not put a server key or identity secret in app or website code, and do not compute identity signatures on the client.
- Do not remove, replace or disable existing push providers, Firebase handlers (`onMessage`, `onMessageOpenedApp`, `onBackgroundMessage`, `FirebaseMessagingService`) or service workers without asking. Add Perchpush beside them.
- Do not call `unsubscribe()` on sign-out: it is a durable opt-out that only a server key can lift. Use `logout()`.
- Do not call `configure`/`init` more than once, or before `Firebase.initializeApp` on Flutter.
- Do not register a second service worker at scope `/`; use `importScripts` in the existing one.
- Do not invent SDK methods or options, or swap in another package when an install fails. Stop and report the exact error.
- Do not change the app's bundle id / applicationId or Firebase project to make things match. Ask the human.

## SDK reference (verified names only)

**Flutter** (`Perchpush`, all static; `pushpal_flutter/lib/src/client.dart`)
- `configure({required String appKey, String baseUrl, bool debug, bool trackSessions, String? appVersion, String? appBuild, String? deviceModel, String? timezone})`
- `attachFirebase(messaging, {Stream? onMessageOpenedApp, Stream? onMessage, void Function(String url)? onUrl, bool requestPermission = true, bool useApnsToken = false, String? vapidKey})`
- `registerPushToken(String token, {PerchpushChannel? channel, bool? notificationsEnabled})`
- `identify(String userId, {String? signature, Map<String, Object?>? attributes, String? email, String? phone})`, `logout()`, `unsubscribe()`, `setSubscribed(bool)`
- `track(String event, {Map<String, Object?>? properties})`, `trackPurchase(double amount, {String currency, String? productId, Map? properties})`, `setTags(Map<String, Object?>)`, `removeTags(List<String>)`
- `onNotificationOpened` / `onNotificationReceived` (`Stream<PerchpushNotification>`: `title`, `body`, `imageUrl`, `campaignId`, `route`, `url`, `buttons`, `buttonId`, `data`, `targetRoute`, `targetUrl`), `trackNotificationOpen(Map data, {String? buttonId})`, `reportReceived(Map data)`
- `enableInAppMessages({required GlobalKey<NavigatorState> navigatorKey, Future<void> Function()? onPromptPush, bool live = true})`, `setTrigger(String, Object?)`, `pauseInAppMessages()`, `resumeInAppMessages()`
- `inbox({int limit = 50})`, `inboxMarkRead(List<String> ids)`, `topics()`, `setTopic(String key, bool subscribed)`, `androidChannels()`
- `handleDeepLink(Uri)`, `setInstallReferrer(String)`, `runSetupTest()`; getters `token`, `userId`, `isSubscribed`

**React Native** (`import Perchpush from '@perchpush/react-native'`; `sdks/react-native/src/index.ts`)
- `configure({ appKey, platform, baseUrl?, storage?, appVersion?, deviceModel?, osVersion?, locale?, timezone?, trackSessions?, debug? })`
- `registerPushToken(token, { channel?, notificationsEnabled?, clickId? })`
- `identify(id, { signature?, attributes?, email?, phone? })`, `logout()`, `unsubscribe()`, `setTags(tags)`, `track(event, properties?)`, `trackPurchase(amount, { currency?, productId? })`
- `trackNotificationOpen(data, buttonId?)`, `onNotificationOpened(fn)` → unsubscribe, `reportReceived(data)`, `handleDeepLink(url)`
- `inAppMessages()`, `reportInApp(id, 'impression' | 'click' | 'dismiss', button?)`, `inbox()`, `inboxMarkRead(ids)`, `topics()`, `setTopic(key, subscribed)`, `runSetupTest()`, getter `token`

**iOS** (`enum Perchpush`; `Sources/Perchpush/Perchpush.swift`)
- `configure(appKey:baseURL:debug:)`, `requestPermission(_ completion: (Bool) -> Void)`
- `registerDeviceToken(_ data: Data)` (direct APNs), `registerFCMToken(_ token: String)`
- `handleNotificationResponse(_:)`, `trackNotificationOpen(userInfo:buttonId:)`, `reportReceived(userInfo:completion:)`, `onNotificationOpened: ((PerchpushNotification) -> Void)?`
- `identify(_:signature:attributes:email:phone:)`, `logout()`, `unsubscribe()`, `setTags(_:)`, `track(_:properties:)`, `trackPurchase(_:currency:productId:)`, `setTopic(_:subscribed:)`, `handleDeepLink(_ url: URL)`; `token`, `userId`

**Android** (`object Perchpush`; `com.neptechpal.perchpush`)
- `init(context, appKey, baseUrl = null, debug = false)`, `registerToken(token, notificationsEnabled = null)`
- `handleOpen(extras: Bundle?, buttonId = null)`, `trackNotificationOpen(data, buttonId = null)`, `reportReceived(data)`, `onNotificationOpened: ((PerchpushNotification) -> Unit)?`
- `identify(userId, signature = null, attributes = null, email = null, phone = null)`, `logout()`, `unsubscribe()`, `setTags(map)`, `track(event, properties = emptyMap())`, `trackPurchase(amount, currency = "USD", productId = null)`, `setTopic(key, subscribed)`, `setInstallReferrer(referrer)`, `handleDeepLink(uri)`; `token`, `userId`

**Web** (`window.Perchpush`; `/sdk/perchpush.js`)
- `init({ appKey, baseUrl?, serviceWorker?, debug?, inApp?, prompt? })`, `isSupported()`, `requestPermission()` → `Promise<boolean>`, `showPrompt(opts?)`
- `identify(userId, { signature?, attributes?, email?, phone? })`, `logout()`, `unsubscribe()`, `setTags(tags)`
- `track(event, properties?)`, `trackPurchase(amount, { currency?, productId? })`
- `on('opened' | 'message', fn)` → unsubscribe function
- `inbox()`, `inboxMarkRead(ids)`, `topics()`, `setTopic(key, subscribed)`, `setTrigger(key, value)`, `pauseInAppMessages()`, `resumeInAppMessages()`, `runSetupTest()`, getters `token`, `version`

## Links

- Full docs: https://perchpush.com/docs (REST API: https://perchpush.com/docs#rest, identity: https://perchpush.com/docs#identity)
- Every SDK and the REST API as one markdown file: https://perchpush.com/docs/agents.md
- Index for agents: https://perchpush.com/llms.txt
