Intégrer Pushproof

Pushproof mesure combien de notifications push arrivent réellement sur l'appareil de vos utilisateurs. Ce guide couvre l'intégration en trois étapes : l'application mobile, le serveur qui envoie les notifications (FCM/APNs), puis l'API Pushproof pour consulter les statistiques.

Vue d'ensemble

FCM et APNs confirment que le message a été pris en charge par Apple ou Google — pas qu'il est bien arrivé sur le téléphone. Pushproof mesure la réception réelle : le SDK installé dans votre app détecte l'arrivée et envoie un accusé. Votre serveur déclare les envois ; le dashboard affiche le rapport entre notifications envoyées et reçues.

  • SDK (open-source) — s'installe dans votre application. Vous continuez d'envoyer les push via FCM/APNs.
  • Service Pushproof — collecte les accusés, calcule les statistiques et alimente le dashboard.

Premiers pas

  1. Créez un compte sur app.pushproof.dev.
  2. Ajoutez votre application (nom + identifiant) — iOS : Bundle Identifier ; Android : applicationId dans build.gradle.
  3. Copiez vos clés API depuis le dashboard (voir ci-dessous).
  4. Transmettez la Partie 1 à votre équipe mobile, puis la Partie 2 à l'équipe qui gère l'envoi des notifications.

Vos clés API

CléEn-tête HTTPOù l'utiliser
ingest_key publique X-Ingest-Key: pk_ingest_… Dans l'app mobile (SDK). Envoie des accusés uniquement.
read_key secrète Authorization: Bearer sk_read_… Sur votre backend ou scripts. Jamais dans l'app.
La read_key expose toutes vos stats : gardez-la côté serveur uniquement.

Partie 1Application mobile

Configuration du SDK dans votre application (Capacitor, iOS et Android) pour enregistrer les notifications reçues.

Installation

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

Le plugin enveloppe le SDK natif iOS et Android. Vous conservez FCM/APNs pour l'envoi.

Capacitor 8 + Swift Package Manager

Sous Capacitor 8, les plugins iOS passent par Swift Package Manager. Après l'installation du package, npx cap sync ios relie automatiquement le plugin Pushproof à votre projet Xcode.

  1. Installez @pushproof/capacitor@1.3.4 ou plus récent (voir Installation).
  2. Lancez npx cap sync ios.
  3. Ouvrez le projet dans Xcode, faites un Clean Build et testez sur un appareil réel.

En cas d'avertissement à la synchronisation

Si tout se passe bien, vous n'avez rien d'autre à faire. En revanche, si npx cap sync ios affiche un message du type :

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

le plugin n'a pas été relié automatiquement. Ajoutez-le manuellement dans ios/App/CapApp-SPM/Package.swift :

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

Relancez npx cap sync ios, puis reconstruisez l'app dans Xcode.

Configuration

Appelez configure() au démarrage de l'app, avant tout envoi de push de test :

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

await Pushproof.configure({
  ingestUrl: 'https://api.pushproof.dev/v1/receipts',
  ingestKey: 'pk_ingest_…',
  appGroup:  'group.com.example.app',  // iOS uniquement — identique à la NSE
  // displayNotification: true,  // Android — défaut
});

configure() persiste la config dans l'App Group pour que la NSE (processus séparé) puisse envoyer des accusés. Pushproof ne gère pas l'enregistrement des tokens FCM : continuez avec @capacitor/push-notifications ou votre stack existante.

Au login / logout (plan Pro) :

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

Extension iOS (NSE)

Sur iOS, la capture en arrière-plan passe par une Notification Service Extension réveillée par iOS avant l'affichage du push, si le payload contient mutable-content: 1 (voir Partie 2).

  1. Xcode → File → New → Target → Notification Service Extension, nommée PushproofNotificationExtension (recommandé).
  2. Ajoutez le package Swift https://github.com/csurbier/pushproofsdk (v1.3.3+) :
    • PushproofCore → cible App
    • PushproofNSE (produit SPM, pas votre cible) → cible extension
  3. Remplacez NotificationService.swift par :
    import PushproofNSE
    class NotificationService: PushproofNotificationService {}
  4. App Groups : activez la même capability sur l'App et la NSE (ex. group.com.example.app).
  5. Dans le Info.plist de la NSE, à la racine (pas dans NSExtension) :
    <key>PushproofAppGroup</key>
    <string>group.com.example.app</string>
  6. Commitez le dossier ios/ — la NSE vit dans votre projet Xcode.
Ne nommez pas la cible Xcode PushproofNSE : c'est le nom du produit SPM. Si le nom est déjà pris, définissez PRODUCT_MODULE_NAME = PushproofNotificationExt sur la cible extension.

Configuration Android

Ajoutez le service Pushproof dans android/app/src/main/AndroidManifest.xml, à l'intérieur de la balise <application> :

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

Si vous avez déjà un service FCM

Ne déclarez pas un second service. Appelez plutôt PushproofMessagingService.handle(this, message) depuis votre onMessageReceived existant.

Affichage des notifications sur Android

Sur Android, votre serveur doit envoyer le titre et le texte dans le champ data du message FCM (détails dans la Partie 2), et non dans le bloc notification de Firebase. Ce format permet au SDK Pushproof d'enregistrer la réception et d'afficher la notification.

Par défaut, displayNotification: true dans configure(), le SDK affiche la notification à l'utilisateur selon l'état de l'app :

  • App ouverte — Capacitor déclenche pushNotificationReceived : affichez le message dans l'app (toast, modal, etc.) comme vous le faites déjà.
  • App en arrière-plan ou fermée — le SDK affiche une notification système à partir de data.title et data.body reçus du serveur.

Pour gérer l'affichage vous-même en arrière-plan, passez displayNotification: false dans configure().

App ouverte : recordDelivery()

Indispensable sur iOS en premier plan. Sans cela, les accusés manquent quand l'app est ouverte.
PushNotifications.addListener('pushNotificationReceived', (notif) => {
  const notifId = notif.data?.notif_id;
  if (notifId) {
    Pushproof.recordDelivery({ notifId, campaign: notif.data?.campaign });
  }
});

Les doublons sont ignorés pour un même couple (notif_id, appareil). Sur Android, cet appel est optionnel.

Au retour en avant-plan, vous pouvez rejouer les accusés mis en file par la NSE :

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

Quand l'utilisateur ouvre la notification

L'événement pushNotificationActionPerformed se déclenche quand l'utilisateur tape sur la notification. C'est l'endroit prévu pour ouvrir un écran, afficher un message ou lancer une action dans l'app.

  • Traitez le tap même si la notification a déjà été reçue alors que l'app était ouverte — l'utilisateur choisit explicitement d'ouvrir la notification.
  • Si vous avez une logique qui évite d'afficher deux fois le même contenu, réinitialisez-la au tap.
  • Récupérez notif_id, campaign et vos propres champs dans notification.data (ignorez les clés système aps, gcm.*).

Squelette complet d'un AppComponent : configuration au démarrage, identité au login/logout, et les deux listeners routés vers un même onPush() qui (1) accuse la réception et (2) déclenche une action métier — ici, ouvrir une popup de notation.

// 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) Configurer une fois au démarrage
    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 : renvoyer les accusés mis en file par la NSE

    // 2) Écouter les push (les deux passent par onPush)
    PushNotifications.addListener('pushNotificationReceived',
      (n) => this.onPush(n.data));                 // app ouverte
    PushNotifications.addListener('pushNotificationActionPerformed',
      (a) => this.onPush(a.notification.data));     // tap utilisateur
    await PushNotifications.register();
  }

  // 3) Depuis votre flux d'authentification
  async onLogin(userId: string) { await Pushproof.identify({ userId }); }
  async onLogout()              { await Pushproof.clearIdentity(); }

  // 4) Routeur d'action
  onPush(data: any) {
    const notifId = data?.notif_id ?? data?.notifId;
    if (notifId) Pushproof.recordDelivery({ notifId, campaign: data?.campaign });

    if (data?.notationRefCommerce) {
      this.openRatingPopup(data.notationRefCommerce, data?.nomCommerce);
    }
    // … autres cas : data?.newProduct, data?.pushCible, 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() sur les deux listeners est volontaire (réception + tap) — le serveur déduplique sur (notif_id, appareil). Appelez onLogin() à la connexion, onLogout() à la déconnexion, et rappelez flushPending() au retour en avant-plan (iOS).

Natif pur (sans Capacitor)

SDK open-source : github.com/csurbier/pushproofsdk. Même protocole, mêmes endpoints. Suivez les sections NSE et backend.

// 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_…")

Partie 2Serveur d'envoi (FCM / APNs)

Chaque notification envoyée doit contenir certains champs et être déclarée à Pushproof. Cette section concerne le serveur qui envoie les push — pas l'API de consultation des statistiques (Partie 3).

Flux d'envoi

  1. Générer un notif_id (UUID) par notification.
  2. Injecter notif_id (+ optionnel campaign) dans le payload FCM/APNs.
  3. Envoyer via FCM (iOS et Android avec des règles différentes — voir ci-dessous).
  4. Déclarer l'envoi via POST /v1/sent (même campaign si utilisé).
  5. Les appareils envoient les accusés via le SDK → POST /v1/receipts (automatique).

Payload commun

  • notif_id — UUID, obligatoire. Le SDK le relit et ne le génère jamais.
  • campaign — libellé optionnel, identique à celui de POST /v1/sent.
  • title et body — dans le bloc data FCM (toujours).
  • user_id — optionnel, mono-destinataire uniquement ; en batch, utilisez identify() côté app.

Payload iOS

  • Notification visible (titre + corps dans aps.alert).
  • mutable-content: 1 dans aps — sinon la NSE ne se réveille jamais.
  • Un push silencieux / data-only ne déclenche pas la NSE sur iOS.
  • Ajoutez notif_id et campaign dans FCM data et en champs personnalisés APNs (au même niveau que aps) pour que l'extension iOS les lise dans userInfo.
Firebase Admin Python : utilisez mutable_content=True (booléen), pas mutable_content=1 (entier) — sinon la clé mutable-content est absente du JSON APNs réellement envoyé.
# 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,  # ← booléen obligatoire
)
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 — extrait
{
  "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": "…"
    }
  }
}

Payload Android

  • Message data-only : pas de bloc notification au niveau FCM.
  • title et body dans data — le SDK les affiche.
  • Priorité haute : android: { priority: 'high' }.
message = Message(
    data={
        'notif_id': notif_id,
        'campaign': campaign_id,
        'title': title,
        'body': body,
        # … vos champs métier
    },
    android=AndroidConfig(priority='high'),
    token=device_token,
)

Envoi mixte iOS + Android : construisez des messages séparés par plateforme (data-only pour Android, notification + apns pour iOS).

Déclarer l'envoi (POST /v1/sent)

Après chaque envoi (ou lot), indiquez combien de notifications ont été envoyées. C'est ce chiffre qui sert de base au calcul du taux de livraison. Utilisez le même libellé campaign que dans le message si vous suivez vos campagnes séparément. Détail complet : POST /v1/sent.

Exemple bout-en-bout

  1. App : configure() + NSE + listeners (Partie 1).
  2. Backend : pour chaque push, UUID → payload iOS/Android → FCM → POST /v1/sent.
  3. Dashboard : comparer « envoyés » et « accusés » sur la période.

Partie 3API Pushproof

Référence des endpoints pour déclarer les envois, consulter les statistiques ou brancher Pushproof sur un serveur existant. URL de base : https://api.pushproof.dev, préfixe /v1, corps au format JSON.

Authentification

OpérationEn-tête
Ingestion accusés / déclaration envoisX-Ingest-Key: pk_ingest_…
Lecture statsAuthorization: Bearer sk_read_…

POST /v1/receipts

Enregistrement d'un accusé de réception. Envoyé automatiquement par le SDK (extension iOS ou service Android). Les doublons sont ignorés pour un même couple (notif_id, appareil).

ChampTypeRequisDescription
notifIdUUIDouiLu dans le payload reçu (jamais généré par le SDK).
devicestringouiIdentifiant d'installation ; hashé côté serveur.
platform"ios" | "android"ouiPlateforme de réception.
campaignstringnonLibellé de campagne.
receivedAtISO 8601nonHorodatage (défaut : maintenant).
userIdstringnon ProIdentifiant opaque, hashé à l'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 }

Un doublon renvoie { "accepted": true, "duplicate": true } sans recompter.

POST /v1/sent

Déclaration d'un envoi (base du calcul du taux). Authentification : X-Ingest-Key.

ChampTypeRequisDescription
platform"ios" | "android"ouiPlateforme visée.
campaignstringnonLibellé de campagne.
dateYYYY-MM-DDnonJour d'envoi (défaut : aujourd'hui).
countintnonNombre d'envois (défaut : 1 ou nb de userIds).
notifIdUUIDnon ProPour la liste « pas reçu ».
userIdsstring[]non ProDestinataires visés (hashés).

GET /v1/stats

Statistiques agrégées. Auth : Authorization: Bearer sk_read_…. Abonnement actif requis.

ParamDescription
range7d · 30d · 90d (défaut 30d)
from & toPlage YYYY-MM-DD (prioritaire sur range)
curl "https://api.pushproof.dev/v1/stats?range=30d" \
  -H "Authorization: Bearer sk_read_…"

Endpoints Pro

GET /v1/receipts/lookup?notif_id=…&user_id=… — « cet utilisateur a-t-il reçu cette notif ? »

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

GET /v1/notifications/{notif_id}/recipients?status=missing — liste paginée des destinataires.

DELETE /v1/users/purge?user_id=… — effacement RGPD.

Codes d'erreur

CodeSignification
202Accusé / envoi accepté.
400Payload invalide.
401Clé de lecture absente ou invalide.
403Clé d'ingestion manquante, abonnement inactif, ou route Pro sur compte non-Pro.
429Rate limiting par app dépassé.

Lire le dashboard

  • Push envoyées — nombre déclaré par votre serveur via POST /v1/sent.
  • Accusés reçus — confirmations remontées par les appareils via le SDK.
  • Taux de livraison — rapport entre accusés et envoyés. Sur iOS, considérez ce chiffre comme un minimum : le taux réel peut être légèrement plus élevé.

Plan Pro

Suivi par utilisateur via identify() dans l'app. Pour les envois groupés, le message est identique pour tous les destinataires : associez chaque appareil à un utilisateur avec identify() plutôt que d'ajouter un user_id par destinataire dans le payload.

Dépannage

SymptômeCause probableSolution
Push reçu, 0 accusémutable-content absent du JSON APNs (ex. mutable_content=1 entier en Python)Payload iOS
cap sync warn Package.swiftPlugin non lié en SPM Capacitor 8Capacitor SPM
configure() OK, NSE silencieuseApp Group / PushproofAppGroup / entitlementsNSE
Accusé seulement au clicNSE ne tourne pas ; seul recordDelivery au tapmutable-content + rebuild
Contenu in-app absent au tapUn flag empêche le code de tap de s'exécuterOuverture notification
import PushproofNSE échoueCible Xcode nommée comme le produit SPMRenommer cible

Limites iOS

Sur iOS, le taux affiché est un minimum : le système peut interrompre l'extension avant que l'accusé soit envoyé au serveur.