Integrate Pushproof

Pushproof measures how many push notifications actually reach your users' devices. This guide covers integration in three steps: the mobile app, the server that sends notifications (FCM/APNs), and the Pushproof API for viewing statistics.

Overview

FCM and APNs confirm the message was accepted by Apple or Google — not that it reached the phone. Pushproof measures actual delivery: the SDK in your app detects arrival and sends a receipt. Your server declares sends; the dashboard shows the ratio between notifications sent and received.

  • SDK (open-source) — installed in your application. You keep sending pushes via FCM/APNs.
  • Pushproof service — collects receipts, computes statistics, and powers the dashboard.

Getting started

  1. Create an account at app.pushproof.dev.
  2. Add your application (name + identifier) — iOS: Bundle Identifier; Android: applicationId in build.gradle.
  3. Copy your API keys from the dashboard (see below).
  4. Share Part 1 with your mobile team, then Part 2 with whoever sends notifications.

Your API keys

KeyHTTP headerWhere to use
ingest_key public X-Ingest-Key: pk_ingest_… In the mobile app (SDK). Sends receipts only.
read_key secret Authorization: Bearer sk_read_… On your backend or scripts. Never in the app.
The read_key exposes all your stats — keep it server-side only.

Part 1Mobile application

SDK setup in your application (Capacitor, iOS and Android) to record received notifications.

Installation

npm install @pushproof/capacitor@1.3.4
npx cap sync ios
npx cap sync android

The plugin wraps the native iOS and Android SDK. You keep FCM/APNs for sending.

Capacitor 8 + Swift Package Manager

On Capacitor 8, iOS plugins go through Swift Package Manager. After installing the package, npx cap sync ios automatically links the Pushproof plugin to your Xcode project.

  1. Install @pushproof/capacitor@1.3.4 or newer (see Installation).
  2. Run npx cap sync ios.
  3. Open the project in Xcode, do a Clean Build, and test on a real device.

If sync shows a warning

When everything works, you are done. However, if npx cap sync ios prints something like:

[warn] @pushproof/capacitor does not have a Package.swift

the plugin was not linked automatically. Add it manually in ios/App/CapApp-SPM/Package.swift:

.package(name: "PushproofCapacitor", path: "../../../node_modules/@pushproof/capacitor"),
// …
.product(name: "PushproofCapacitor", package: "PushproofCapacitor"),

Re-run npx cap sync ios, then rebuild the app in Xcode.

Configuration

Call configure() at app startup, before any test push:

import { Pushproof } from '@pushproof/capacitor';

await Pushproof.configure({
  ingestUrl: 'https://api.pushproof.dev/v1/receipts',
  ingestKey: 'pk_ingest_…',
  appGroup:  'group.com.example.app',  // iOS only — must match NSE
  // displayNotification: true,  // Android — default
});

configure() persists config in the App Group so the NSE (separate process) can post receipts. Pushproof does not register FCM tokens — keep using @capacitor/push-notifications or your existing stack.

On login / logout (Pro plan):

await Pushproof.identify({ userId: 'usr_opaque' });
await Pushproof.clearIdentity();

iOS extension (NSE)

On iOS, background capture uses a Notification Service Extension that iOS wakes before displaying the push when the payload includes mutable-content: 1 (see Part 2).

  1. Xcode → File → New → Target → Notification Service Extension, named PushproofNotificationExtension (recommended).
  2. Add Swift package https://github.com/csurbier/pushproofsdk (v1.3.3+):
    • PushproofCoreApp target
    • PushproofNSE (SPM product, not your target) → extension target
  3. Replace NotificationService.swift with:
    import PushproofNSE
    class NotificationService: PushproofNotificationService {}
  4. App Groups: enable the same capability on App and NSE (e.g. group.com.example.app).
  5. In the NSE Info.plist, at the root (not inside NSExtension):
    <key>PushproofAppGroup</key>
    <string>group.com.example.app</string>
  6. Commit the ios/ folder — the NSE lives in your Xcode project.
Do not name the Xcode target PushproofNSE — that is the SPM product name. If the name is taken, set PRODUCT_MODULE_NAME = PushproofNotificationExt on the extension target.

Android setup

Add the Pushproof service in android/app/src/main/AndroidManifest.xml, inside the <application> tag:

<service
  android:name="dev.pushproof.PushproofMessagingService"
  android:exported="false">
  <intent-filter>
    <action android:name="com.google.firebase.MESSAGING_EVENT" />
  </intent-filter>
</service>

If you already have an FCM service

Do not declare a second service. Instead, call PushproofMessagingService.handle(this, message) from your existing onMessageReceived.

How notifications appear on Android

On Android, your server must send the title and body in the FCM message data field (see Part 2), not in Firebase's notification block. This format lets the Pushproof SDK record delivery and display the notification.

By default, with displayNotification: true in configure(), the SDK shows the notification to the user depending on app state:

  • App open — Capacitor fires pushNotificationReceived: show the message in-app (toast, modal, etc.) as you already do.
  • App in background or closed — the SDK shows a system notification from data.title and data.body sent by your server.

To handle background display yourself, set displayNotification: false in configure().

App in foreground: recordDelivery()

Required on iOS in foreground. Without it, receipts are missed when the app is open.
PushNotifications.addListener('pushNotificationReceived', (notif) => {
  const notifId = notif.data?.notif_id;
  if (notifId) {
    Pushproof.recordDelivery({ notifId, campaign: notif.data?.campaign });
  }
});

Duplicates are ignored for the same (notif_id, device) pair. On Android, this call is optional.

On resume, replay receipts queued by the NSE:

const { receipts } = await Pushproof.getPendingReceipts();
for (const r of receipts) {
  await Pushproof.recordDelivery({ notifId: r.notifId, campaign: r.campaign });
}

When the user opens the notification

The pushNotificationActionPerformed event fires when the user taps the notification. Use it to open a screen, show a message, or trigger an in-app action.

  • Handle the tap even if the notification was already received while the app was open — the user is explicitly choosing to open it.
  • If you have logic that prevents showing the same content twice, reset it on tap.
  • Read notif_id, campaign, and your own fields from notification.data (skip system keys aps, gcm.*).

Full AppComponent skeleton: configure at startup, identity at login/logout, and both listeners routed to one onPush() that (1) confirms the receipt and (2) triggers a business action — here, opening a rating popup.

// app.component.ts (Ionic / Angular)
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
import { Pushproof } from '@pushproof/capacitor';

export class AppComponent {
  async ngOnInit() {
    if (!Capacitor.isNativePlatform()) return;

    // 1) Configure once at startup
    await Pushproof.configure({
      ingestUrl: 'https://api.pushproof.dev/v1/receipts',
      ingestKey: 'pk_ingest_…',
      ...(Capacitor.getPlatform() === 'ios'
        ? { appGroup: 'group.com.example.app' } : {}),
    });
    await this.flushPending();   // iOS: resend receipts the NSE queued offline

    // 2) Listen for pushes (both go through onPush)
    PushNotifications.addListener('pushNotificationReceived',
      (n) => this.onPush(n.data));                 // app open
    PushNotifications.addListener('pushNotificationActionPerformed',
      (a) => this.onPush(a.notification.data));     // user tap
    await PushNotifications.register();
  }

  // 3) From your auth flow
  async onLogin(userId: string) { await Pushproof.identify({ userId }); }
  async onLogout()              { await Pushproof.clearIdentity(); }

  // 4) Action router
  onPush(data: any) {
    const notifId = data?.notif_id ?? data?.notifId;
    if (notifId) Pushproof.recordDelivery({ notifId, campaign: data?.campaign });

    if (data?.rateCommerceId) {
      this.openRatingPopup(data.rateCommerceId, data?.commerceName);
    }
    // … other cases: data?.newProduct, data?.targetedMessage, etc.
  }

  private async flushPending() {
    if (Capacitor.getPlatform() !== 'ios') return;
    const { receipts } = await Pushproof.getPendingReceipts();
    for (const r of receipts) {
      await Pushproof.recordDelivery({ notifId: r.notifId, campaign: r.campaign });
    }
  }
}
Calling recordDelivery() on both listeners is intentional (received + tap) — the server deduplicates on (notif_id, device). Call onLogin() at login, onLogout() at logout, and re-run flushPending() on resume (iOS).

Pure native (without Capacitor)

Open-source SDK: github.com/csurbier/pushproofsdk. Same protocol and endpoints. Follow NSE and backend sections.

// iOS — PushproofCore
Pushproof.shared.configure(
  ingestUrl: "https://api.pushproof.dev/v1/receipts",
  ingestKey: "pk_ingest_…",
  appGroup:  "group.com.example.app"
)

// Android — JitPack com.github.csurbier:pushproofsdk:1.3.3
PushproofCore.configure(context, "https://api.pushproof.dev/v1/receipts", "pk_ingest_…")

Part 2Sending server (FCM / APNs)

Each notification you send must include certain fields and be declared to Pushproof. This section covers the server that sends pushes — not the statistics API (Part 3).

Send flow

  1. Generate a notif_id (UUID) per notification.
  2. Inject notif_id (+ optional campaign) into the FCM/APNs payload.
  3. Send via FCM (different rules for iOS vs Android — below).
  4. Declare the send via POST /v1/sent (same campaign if used).
  5. Devices post receipts via the SDK → POST /v1/receipts (automatic).

Common payload

  • notif_id — UUID, required. The SDK reads it and never generates it.
  • campaign — optional label, identical to POST /v1/sent.
  • title and body — in FCM data (always).
  • user_id — optional, single-recipient only; for batch use identify() on the app.

iOS payload

  • Visible notification (title + body in aps.alert).
  • mutable-content: 1 in aps — otherwise the NSE never wakes.
  • A silent / data-only push does not trigger the NSE on iOS.
  • Put notif_id and campaign in FCM data and as custom APNs fields (alongside aps) so the iOS extension reads them in userInfo.
Firebase Admin Python: use mutable_content=True (boolean), not mutable_content=1 (integer) — otherwise mutable-content is missing from the actual APNs JSON sent.
# Firebase Admin SDK (Python)
from firebase_admin.messaging import Message, Notification, Aps, ApsAlert, APNSConfig, APNSPayload

aps = Aps(
    alert=ApsAlert(title=title, body=body),
    badge=1, sound='default',
    mutable_content=True,  # ← boolean required
)
apns_payload = APNSPayload(
    aps=aps,
    notif_id=str(notif_id),
    campaign=str(campaign_id),
)
apns = APNSConfig(
    headers={'apns-push-type': 'alert', 'apns-priority': '10'},
    payload=apns_payload,
)
message = Message(
    notification=Notification(title=title, body=body),
    data={'notif_id': notif_id, 'campaign': campaign_id, 'title': title, 'body': body},
    apns=apns,
    token=device_token,
)
// FCM HTTP v1 — excerpt
{
  "message": {
    "token": "<device_token>",
    "notification": { "title": "…", "body": "…" },
    "apns": {
      "headers": { "apns-push-type": "alert", "apns-priority": "10" },
      "payload": {
        "aps": {
          "alert": { "title": "…", "body": "…" },
          "mutable-content": 1,
          "sound": "default"
        },
        "notif_id": "8f14e45f-ceea-467d-9a3b-2c1d4f5e6a7b",
        "campaign": "promo_2026_06"
      }
    },
    "data": {
      "notif_id": "8f14e45f-ceea-467d-9a3b-2c1d4f5e6a7b",
      "campaign": "promo_2026_06",
      "title": "…", "body": "…"
    }
  }
}

Android payload

  • Data-only message: no top-level FCM notification block.
  • title and body in data — the SDK displays them.
  • High priority: android: { priority: 'high' }.
message = Message(
    data={
        'notif_id': notif_id,
        'campaign': campaign_id,
        'title': title,
        'body': body,
    },
    android=AndroidConfig(priority='high'),
    token=device_token,
)

Mixed iOS + Android sends: build separate messages per platform (data-only for Android, notification + apns for iOS).

Declare sends (POST /v1/sent)

After each send (or batch), report how many notifications were sent. This number is the basis for the delivery rate. Use the same campaign label as in the message if you track campaigns separately. Full detail: POST /v1/sent.

End-to-end example

  1. App: configure() + NSE + listeners (Part 1).
  2. Backend: per push, UUID → iOS/Android payload → FCM → POST /v1/sent.
  3. Dashboard: compare “sent” vs “receipts” over the period.

Part 3Pushproof API

Endpoint reference for declaring sends, viewing statistics, or connecting Pushproof to an existing server. Base URL: https://api.pushproof.dev, prefix /v1, JSON bodies.

Authentication

OperationHeader
Receipt ingestion / send declarationX-Ingest-Key: pk_ingest_…
Stats readAuthorization: Bearer sk_read_…

POST /v1/receipts

Records a delivery receipt. Sent automatically by the SDK (iOS extension or Android service). Duplicates are ignored for the same (notif_id, device) pair.

FieldTypeRequiredDescription
notifIdUUIDyesRead from received payload (never SDK-generated).
devicestringyesInstall identifier; hashed server-side.
platform"ios" | "android"yesReceiving platform.
campaignstringnoCampaign label.
receivedAtISO 8601noTimestamp (default: now).
userIdstringno ProOpaque id, hashed at ingestion.
curl -X POST https://api.pushproof.dev/v1/receipts \
  -H "X-Ingest-Key: pk_ingest_…" \
  -H "Content-Type: application/json" \
  -d '{
    "notifId": "8f14e45f-ceea-467d-9a3b-2c1d4f5e6a7b",
    "device": "<install_id>",
    "platform": "ios",
    "campaign": "promo_2026_06"
  }'

// 202 Accepted
{ "accepted": true, "duplicate": false }

A duplicate returns { "accepted": true, "duplicate": true } without double-counting.

POST /v1/sent

Declares a send (basis for the delivery rate). Authentication: X-Ingest-Key.

FieldTypeRequiredDescription
platform"ios" | "android"yesTarget platform.
campaignstringnoCampaign label.
dateYYYY-MM-DDnoSend day (default: today).
countintnoSend count (default: 1 or userIds length).
notifIdUUIDno ProFor “missing” recipient lists.
userIdsstring[]no ProIntended recipients (hashed).

GET /v1/stats

Aggregated stats. Auth: Authorization: Bearer sk_read_…. Active subscription required.

ParamDescription
range7d · 30d · 90d (default 30d)
from & toYYYY-MM-DD range (overrides range)
curl "https://api.pushproof.dev/v1/stats?range=30d" \
  -H "Authorization: Bearer sk_read_…"

Pro endpoints

GET /v1/receipts/lookup?notif_id=…&user_id=… — “did this user receive this notification?”

{ "received": true, "received_at": "2026-06-24T13:08:18Z", "platform": "android" }

GET /v1/notifications/{notif_id}/recipients?status=missing — paginated recipient list.

DELETE /v1/users/purge?user_id=… — GDPR erasure.

Error codes

CodeMeaning
202Receipt / send accepted.
400Invalid payload.
401Missing or invalid read key.
403Missing ingest key, inactive subscription, or Pro route on non-Pro account.
429Per-app rate limit exceeded.

Reading the dashboard

  • Sent pushes — count declared by your server via POST /v1/sent.
  • Receipts — confirmations reported by devices via the SDK.
  • Delivery rate — ratio of receipts to sends. On iOS, treat this as a minimum — the actual rate may be slightly higher.

Pro plan

Per-user tracking via identify() in the app. For batch sends, the message is identical for all recipients: associate each device with a user via identify() rather than adding a user_id per recipient in the payload.

Troubleshooting

SymptomLikely causeFix
Push received, 0 receiptsmutable-content missing from APNs JSON (e.g. Python mutable_content=1 integer)iOS payload
cap sync Package.swift warnPlugin not linked in Capacitor 8 SPMCapacitor SPM
configure() OK, silent NSEApp Group / PushproofAppGroup / entitlementsNSE
Receipt only on tapNSE not running; only recordDelivery on tap worksmutable-content + rebuild
In-app content missing on tapA flag prevents the tap handler from runningOpening notifications
import PushproofNSE failsXcode target named like SPM productRename target

iOS limits

On iOS, the displayed rate is a minimum — the system may stop the extension before the receipt reaches the server.