Documentation
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
- Create an account at app.pushproof.dev.
- Add your application (name + identifier) — iOS: Bundle Identifier; Android:
applicationIdinbuild.gradle. - Copy your API keys from the dashboard (see below).
- Share Part 1 with your mobile team, then Part 2 with whoever sends notifications.
Your API keys
| Key | HTTP header | Where 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. |
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.
- Install
@pushproof/capacitor@1.3.4or newer (see Installation). - Run
npx cap sync ios. - 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).
- Xcode → File → New → Target → Notification Service Extension, named
PushproofNotificationExtension(recommended). - Add Swift package
https://github.com/csurbier/pushproofsdk(v1.3.3+):- PushproofCore → App target
- PushproofNSE (SPM product, not your target) → extension target
- Replace
NotificationService.swiftwith:import PushproofNSE class NotificationService: PushproofNotificationService {} - App Groups: enable the same capability on App and NSE (e.g.
group.com.example.app). - In the NSE Info.plist, at the root (not inside
NSExtension):<key>PushproofAppGroup</key> <string>group.com.example.app</string> - Commit the
ios/folder — the NSE lives in your Xcode project.
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.titleanddata.bodysent by your server.
To handle background display yourself, set displayNotification: false in configure().
App in foreground: recordDelivery()
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 fromnotification.data(skip system keysaps,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 });
}
}
}
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
- Generate a
notif_id(UUID) per notification. - Inject
notif_id(+ optionalcampaign) into the FCM/APNs payload. - Send via FCM (different rules for iOS vs Android — below).
- Declare the send via
POST /v1/sent(samecampaignif used). - 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 toPOST /v1/sent.titleandbody— in FCMdata(always).user_id— optional, single-recipient only; for batch useidentify()on the app.
iOS payload
- Visible notification (title + body in
aps.alert). mutable-content: 1inaps— otherwise the NSE never wakes.- A silent / data-only push does not trigger the NSE on iOS.
- Put
notif_idandcampaignin FCMdataand as custom APNs fields (alongsideaps) so the iOS extension reads them inuserInfo.
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
notificationblock. titleandbodyindata— 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
- App:
configure()+ NSE + listeners (Part 1). - Backend: per push, UUID → iOS/Android payload → FCM →
POST /v1/sent. - 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
| Operation | Header |
|---|---|
| Receipt ingestion / send declaration | X-Ingest-Key: pk_ingest_… |
| Stats read | Authorization: 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.
| Field | Type | Required | Description |
|---|---|---|---|
notifId | UUID | yes | Read from received payload (never SDK-generated). |
device | string | yes | Install identifier; hashed server-side. |
platform | "ios" | "android" | yes | Receiving platform. |
campaign | string | no | Campaign label. |
receivedAt | ISO 8601 | no | Timestamp (default: now). |
userId | string | no Pro | Opaque 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.
| Field | Type | Required | Description |
|---|---|---|---|
platform | "ios" | "android" | yes | Target platform. |
campaign | string | no | Campaign label. |
date | YYYY-MM-DD | no | Send day (default: today). |
count | int | no | Send count (default: 1 or userIds length). |
notifId | UUID | no Pro | For “missing” recipient lists. |
userIds | string[] | no Pro | Intended recipients (hashed). |
GET /v1/stats
Aggregated stats. Auth: Authorization: Bearer sk_read_…. Active subscription required.
| Param | Description |
|---|---|
range | 7d · 30d · 90d (default 30d) |
from & to | YYYY-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
| Code | Meaning |
|---|---|
202 | Receipt / send accepted. |
400 | Invalid payload. |
401 | Missing or invalid read key. |
403 | Missing ingest key, inactive subscription, or Pro route on non-Pro account. |
429 | Per-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
| Symptom | Likely cause | Fix |
|---|---|---|
| Push received, 0 receipts | mutable-content missing from APNs JSON (e.g. Python mutable_content=1 integer) | iOS payload |
cap sync Package.swift warn | Plugin not linked in Capacitor 8 SPM | Capacitor SPM |
configure() OK, silent NSE | App Group / PushproofAppGroup / entitlements | NSE |
| Receipt only on tap | NSE not running; only recordDelivery on tap works | mutable-content + rebuild |
| In-app content missing on tap | A flag prevents the tap handler from running | Opening notifications |
import PushproofNSE fails | Xcode target named like SPM product | Rename target |