Envoyer un push
Le transport push de Kaiho est un tuyau, pas une plateforme d’engagement : pas de console, pas de séquences automatiques, pas de segments enregistrés. Une campagne est un fichier de texte, relu à la main, envoyé depuis un terminal. Le contexte et les raisons de ce choix sont dans Notifications ; cette page-ci est le mode d’emploi.
Trois gestes, toujours dans cet ordre. L’essai à blanc est le mode par défaut : il faut un drapeau explicite pour en sortir.
- Écrire la campagne. Copier
scripts/campaigns/example.json(danskaiho-backend) et réécrire la copy. - Essai à blanc. Il résout la vraie audience et affiche, langue par langue, le texte exact que ces téléphones liraient — et lesquels retombent sur la traduction par défaut. Rien ne part.
- Envoyer. Même commande, drapeau explicite.
node scripts/send-push.mjs campagnes/ma-campagne.json # essai à blancnode scripts/send-push.mjs campagnes/ma-campagne.json --send # envoi réelLe fichier de campagne
Section intitulée « Le fichier de campagne »Tout ce que le serveur sait d’un envoi tient dans ce fichier. Il n’y a rien de stocké ailleurs : ni planning, ni segment, ni historique.
{ "campaign": "reprise-septembre", "audience": { "user_ids": ["f91b8209-…", "8db0f8fb-…"] }, "message": { "deep_link": "coach", "default": { "title": "…", "body": "…" }, "by_locale": { "fr": { "title": "…", "body": "…" } } }}| Champ | Ce qu’il fait |
|---|---|
campaign |
Slug stable. Il fait trois choses : propriété PostHog pour l’entonnoir, clé de regroupement (un renvoi remplace la bannière au lieu d’en empiler une seconde), et étiquette dans le compte-rendu. Un slug par campagne, jamais réutilisé pour un autre message. |
audience |
{ "all": true } ou { "user_ids": [...] }. Voir ci-dessous. |
deep_link |
coach, tracking, achievements ou profile. Rien d’autre n’existe. Absent ou inconnu : l’app s’ouvre là où elle était. |
default |
Obligatoire. Ce que lit un appareil dont la langue n’est pas prévue. |
by_locale |
Clé = sous-tag principal. pt-BR et pt-PT tombent tous les deux sur pt. |
Choisir qui reçoit
Section intitulée « Choisir qui reçoit »L’outil ne connaît pas de segments. Il attend une liste d’identifiants, produite en SQL. C’est volontaire : la sélection reste lisible et vérifiable avant l’envoi.
| Forme | Quand |
|---|---|
"all": true |
Tous les appareils joignables. À réserver aux annonces qui concernent vraiment tout le monde. |
"user_ids": [...] |
Quelques dizaines d’identifiants, écrits dans le fichier. |
--users-from ids.txt |
Un identifiant par ligne. La bonne forme dès qu’un SQL produit la liste. |
Toutes les requêtes ci-dessous ne renvoient que des gens réellement joignables : la jointure sur user_push_tokens avec notifications_authorized écarte d’office ceux qui n’ont pas l’app à jour ou qui ont coupé les notifications.
-- Non-premium, compte de plus de 7 joursselect distinct t.user_idfrom public.user_push_tokens tjoin public.users u on u.id = t.user_idleft join public.user_entitlements e on e.user_id = t.user_idwhere t.notifications_authorized and coalesce(e.is_premium, false) = false and u.created_at < now() - interval '7 days';
-- Sans date d'arrêt — la plus grosse populationselect distinct t.user_idfrom public.user_push_tokens tjoin public.users u on u.id = t.user_idwhere t.notifications_authorized and u.quit_date is null and u.planned_quit_date is null;
-- Arrêtés depuis exactement 30 jours — un cap à célébrerselect distinct t.user_idfrom public.user_push_tokens tjoin public.users u on u.id = t.user_idwhere t.notifications_authorized and u.quit_date::date = (now() - interval '30 days')::date;
-- N'ont pas ouvert l'app depuis 30 jours — le trou que le push combleselect distinct t.user_idfrom public.user_push_tokens tjoin auth.users a on a.id = t.user_idwhere t.notifications_authorized and a.last_sign_in_at < now() - interval '30 days';
-- Par fuseau — indispensable pour viser une heure localeselect distinct user_idfrom public.user_push_tokenswhere notifications_authorized and timezone = 'Europe/Paris';# Exporter le résultat en CSV depuis l'éditeur SQL Supabase, puis :tail -n +2 export.csv | tr -d '"' > ids.txtnode scripts/send-push.mjs ma-campagne.json --users-from ids.txtEnvoyer par lots
Section intitulée « Envoyer par lots »Une seule commande suffit jusqu’à plusieurs milliers d’appareils. La fonction envoie 32 requêtes en vol, pagine l’audience par 1 000 et découpe les listes d’identifiants par 300 pour rester sous la taille d’URL de PostgREST — tout cela est interne, il n’y a rien à régler.
Ce qui concerne l’expéditeur, c’est le temps d’exécution : une edge function n’a pas l’éternité, et un envoi qui expire à mi-parcours laisse une moitié d’audience servie sans moyen simple de savoir laquelle. Au-delà d’environ 20 000 appareils, découper.
split -l 5000 ids.txt lot-
for f in lot-*; do echo "→ $f" node scripts/send-push.mjs ma-campagne.json --users-from "$f" --send sleep 5doneGarder le même slug d’un lot à l’autre : les destinataires sont disjoints, donc aucun regroupement ne s’applique, et PostHog voit une seule campagne au lieu de quatre.
Lire le résultat
Section intitulée « Lire le résultat »{ "campaign": "reprise-septembre", "audience_users": 1840, "tokens": 1902, "sent": 1889, "failed": 13, "pruned": 13, "by_platform": { "ios": 812, "android": 1090 }, "errors": [ { "reason": "ios 410 Unregistered", "count": 13 } ]}| Champ | Lecture |
|---|---|
audience_users |
Personnes. tokens est plus grand dès que quelqu’un a deux appareils. |
sent |
Accepté par Apple ou Google. Pas « affiché ». |
pruned |
Jetons morts supprimés (désinstallation, jeton tourné). Entretien normal, pas un incident. |
failed sans pruned |
Échecs non terminaux — réseau, transporteur. Les jetons sont gardés, le prochain envoi retentera. |
Les ouvertures se mesurent en entonnoir PostHog, rapproché par la propriété campaign : push_sent (émis par le serveur, un par destinataire, dédupliqué) puis notification_opened avec category = push. Les envois depuis dev n’y apparaissent pas : la clé PostHog est volontairement absente des secrets de dev, parce que les deux environnements partagent le même projet et que des tests y feraient du bruit.
Les limites
Section intitulée « Les limites »Pas de planning. L’envoi part au moment où la commande est lancée. Ni programmation, ni file d’attente. Pour envoyer à 19 h, il faut être là à 19 h — ou passer par un at / un cron côté machine.
Pas de fuseaux. Le fuseau de chaque appareil est connu (il est dans la table) mais la fonction ne s’en sert pas pour étaler l’envoi : tout part en même temps. Pour viser une heure locale, une requête par fuseau et une commande par fuseau. Sur une base majoritairement française, une seule vague suffit le plus souvent.
Pas d’heures calmes. Les notifications locales décalent automatiquement ce qui tomberait entre 22 h et 8 h. Un push, non — l’heure d’envoi est celle de l’expéditeur.
Audience partielle. Un appareil n’entre dans la table qu’après avoir installé la mise à jour et ouvert l’app en étant connecté. Les premiers jours après une publication, l’audience joignable est une fraction de la base ; elle se remplit au rythme des ouvertures.
Un seul canal Android. Les push se posent sur le canal « accompagnement », celui du coach. Conséquence assumée : quelqu’un qui coupe ce canal pour échapper aux annonces coupe aussi les notifications du programme.
Rien de commercial. Une promotion envoyée en push exige, côté Apple, un accord explicite recueilli dans l’app et un moyen de le retirer — règle 4.5.4 de l’App Store. Ce n’est pas la permission système, c’est un consentement séparé, et il n’existe pas encore. Concrètement : annonces, contenu, accompagnement, oui. Offre réduite, code promo, relance d’abonnement, non.
Durée de vie. Un message expire au bout de 24 h. Un téléphone éteint plus longtemps ne le recevra jamais — délibéré : passé un jour, le message est trop périmé pour valoir un réveil.
Force-stop. Un utilisateur Android qui a fait « Forcer l’arrêt » est injoignable : alarmes, tâches et FCM sont tous bloqués par le système jusqu’à la prochaine ouverture manuelle. Non contournable.
Pas d’A/B. Faisable à la main — deux fichiers, deux moitiés d’audience, deux slugs — mais rien ne calcule le résultat.
Environnements
Section intitulée « Environnements »| Prod | Dev | |
|---|---|---|
| Référence projet | qegdvexqosnckhogtffj |
xxzatqdtbteypbfgujfa |
| Par défaut dans le shell | oui | non — à préfixer |
| Alimenté par | schémas Prod | schémas Dev |
push_sent vers PostHog |
oui | non, volontairement |
PUSH_SEND_SECRET existe à deux endroits : le ~/.zshrc et les secrets de chaque projet Supabase. Les changer ensemble, ou la commande renverra 401 Unauthorized sans autre explication.
Les pièges
Section intitulée « Les pièges »Slug recyclé. Réutiliser un campaign déjà envoyé fait remplacer la bannière précédente sur les téléphones qui l’ont encore, et mélange les deux messages dans le même entonnoir.
Audience vide. "user_ids": [] est refusé — une audience vide est une erreur, pas un envoi. Un --users-from pointant sur un fichier vide donne la même chose.
Mauvais projet. Sans préfixe, la commande part sur prod. Le compte-rendu d’essai à blanc est le garde-fou : si on attendait deux appareils de test et qu’on en voit deux mille, on n’est pas sur dev.
Langue oubliée. Une langue absente de by_locale reçoit default sans prévenir. L’essai à blanc marque ces cas "fallback": true — c’est la ligne à lire avant d’envoyer.
PostHog. Le SDK PostHog collecte les jetons d’appareil par défaut (capturePushNotificationSubscriptions) pour son propre produit push, et son swizzling avalait le callback d’enregistrement iOS — aucun appareil ne s’enregistrait. Les deux options push du SDK sont coupées côté app. À revérifier à chaque montée de version du SDK : un défaut qui revient recasserait l’enregistrement en silence.