Se connecter au compte par l'API, poser ses pixels, envoyer ses conversions serveur, publier ses annonces, et surtout vérifier que la chaîne dit la vérité. Tout ce qui se passe entre le clic et le chiffre affiché dans le rapport.
Les mots changent selon l'écran où on se trouve, et deux personnes croient parler de la même chose alors que non. À poser avant tout le reste.
| Ce qu'on dit | Ce que c'est | Où ça vit |
|---|---|---|
| Business Manager, Portfolio | le coffre qui contient tout le reste | business.facebook.com |
| App | l'identité technique qui parle à l'API | developers.facebook.com/apps |
| System User | un « employé robot » porteur du jeton | Paramètres du business |
| Pixel, Dataset | le collecteur d'évènements | Gestionnaire d'évènements |
| Compte publicitaire | là où l'argent est débité | act_<id> |
| Évènement | une action nommée (Lead, Purchase, ou un nom à soi) | dans le pixel |
| CAPI | l'envoi d'évènements depuis ton serveur | ton code |
C'est l'étape que tout le monde rate, parce que l'interface propose partout des jetons qui périment en une heure ou en soixante jours. Un système qui tourne en cron a besoin d'un jeton permanent, et il n'en existe qu'un type : celui d'un System User.
Business Manager
├── App (developers.facebook.com/apps) → app_id + app_secret
└── System User (Paramètres du business → Utilisateurs système)
├── on lui AFFECTE les actifs : compte pub, pixel, page
└── on génère SON jeton, en cochant les permissions
→ ce jeton-là ne périme pas
Les permissions à cocher, ni plus ni moins :
| Permission | Pour quoi |
|---|---|
ads_management | créer et modifier campagnes, annonces, créatifs |
ads_read | lire les dépenses et les résultats |
business_management | lire la structure du business |
pages_read_engagement | dès qu'un créatif référence une page |
Un jeton sans actif affecté ne sert à rien. Générer le jeton ne donne aucun droit. Il faut ensuite, séparément, affecter chaque actif (compte publicitaire, pixel, page) au System User avec le bon niveau d'accès. Symptôme classique : l'API renvoie une liste vide ou (#200) Provide valid app ID alors que le jeton est parfaitement valide. On cherche le problème du mauvais côté pendant une heure.
Un seul System User pour toute la boîte. La tentation est d'en créer un par projet. C'est une erreur : chaque nouveau System User redemande une validation, et on finit avec cinq jetons dont personne ne sait lequel fait quoi. Un jeton maître, et pour un nouvel actif on l'affecte au System User existant. Rien à revalider, rien à redemander.
Le nom d'une campagne ne dit pas où elle est facturée. Une campagne peut porter le nom d'une marque et être débitée sur le compte publicitaire d'une autre. Avant toute lecture de dépense, vérifier le act_<id> réel, jamais le nom affiché.
Trois appels, dans cet ordre. Si l'un échoue, inutile d'aller plus loin.
# 1. le jeton est-il vivant, et pour qui ?
curl -s "https://graph.facebook.com/v21.0/me?access_token=$TOKEN"
# 2. voit-il le compte publicitaire ?
curl -s "https://graph.facebook.com/v21.0/me/adaccounts\
?fields=name,account_status&access_token=$TOKEN"
# 3. voit-il le pixel ?
curl -s "https://graph.facebook.com/v21.0/$BUSINESS_ID/adspixels\
?fields=name,id&access_token=$TOKEN"
Garder la version d'API (v21.0) explicite dans le code. Sans numéro, Meta sert la plus ancienne encore supportée, et le comportement de ton système change tout seul le jour où elle est retirée. Sans que rien ne te prévienne.
Le code de base va sur toutes les pages, y compris celles qu'on croit hors funnel : page de remerciement, page de réservation, pages légales. Une page sans pixel est un trou d'attribution qu'on ne verra jamais, parce qu'un trou ne produit aucune erreur.
Deux marchés, deux publics, deux campagnes distinctes → deux pixels distincts. Mélanger deux audiences dans un même pixel apprend à l'algorithme à chercher un profil moyen qui n'existe pas.
| L'écran affiche | Ce que ça veut vraiment dire |
|---|---|
| un évènement reçu | Meta l'a accepté. Pas qu'il est exploitable. |
| « qualité de correspondance » sur 10 | calculé sur les champs envoyés, non exposé par l'API |
| un volume qui décolle | souvent un envoyeur qui rejoue, pas une hausse réelle |
| un diagnostic « paramètre non valide » | à lire en entier : il donne la valeur fautive |
Utiliser les évènements de test avant de croire quoi que ce soit. L'onglet « Test des évènements » donne un test_event_code : on l'ajoute au payload, l'évènement arrive dans une vue isolée, et il ne pollue pas les données de production. C'est le seul moyen de valider un format sans salir l'historique.
Le principe : le navigateur envoie ce qu'il voit, le serveur envoie ce qui se passe après le clic (un rendez-vous honoré, une vente encaissée). Les deux se rejoignent par un event_id commun, qui sert à dédupliquer.
POST https://graph.facebook.com/v21.0/<PIXEL_ID>/events?access_token=<TOKEN>
{
"data": [{
"event_name": "Purchase",
"event_time": 1753689600, ← date de l'ACTION, pas de l'envoi
"event_id": "vente-4821", ← la clé de déduplication
"action_source": "system_generated",
"user_data": {
"em": "<sha256 de l'email en minuscules>",
"ph": "<sha256 du tél., chiffres seuls avec indicatif>",
"fn": "<sha256 du prénom en minuscules>",
"fbc": "fb.1.<ms>.<identifiant de clic>", ← EN CLAIR
"fbp": "fb.1.<ms>.<aléatoire>", ← EN CLAIR
"client_ip_address": "1.2.3.4", ← EN CLAIR
"client_user_agent": "Mozilla/5.0 …" ← EN CLAIR
},
"custom_data": { "value": 2500, "currency": "EUR" }
}]
}
Ce qui se hache et ce qui ne se hache pas. Les données personnelles en SHA‑256 minuscules sans espaces, mais les identifiants techniques (fbc, fbp, IP, navigateur) en clair. Hacher ces quatre-là revient à les jeter, et aucun message d'erreur ne te le dira.
Campagne objectif + budget (si CBO) /act_<id>/campaigns
└── Ensemble ciblage, budget, optimisation /act_<id>/adsets
└── Annonce pointe vers un créatif /act_<id>/ads
└── Créatif textes, visuels, lien /act_<id>/adcreatives
Toujours créer en PAUSED, à chaque étage. Une annonce créée active dépense pendant que tu vérifies encore qu'elle est correcte. On active à la fin, quand tout est relu.
PAUSED.standard_enhancements dans degrees_of_freedom_spec est déprécié et fait échouer la création avec « Invalid parameter ». Le retirer.image_url est en lecture seule : on envoie image_hash, jamais l'URL.asset_feed_spec. Un clone qui ne recopie que l'identifiant de publication les efface tous, et l'annonce perd son optimisation.Soit sur la campagne (CBO), soit sur l'ensemble. Jamais les deux : Meta accepte la contradiction sans broncher et applique celui qu'il veut.
Une campagne optimise sur un nom d'évènement. Le renommer remet son apprentissage à zéro. Quand on reprend un système existant, on rejoue exactement les mêmes noms, même s'ils sont mal fichus.
Corollaire : si on renomme quand même, le faire en une seule fois, cron arrêté. Sinon la salve qui part avant le renommage et celle qui part après comptent double. Vécu : 57 évènements doublés sur 35 rendez-vous, parce que le renommage est tombé entre deux passages du cron espacés d'une heure.
Deux évènements du calendrier peuvent porter le même libellé et viser deux publics différents. Router sur le nom, c'est ranger des prospects dans le mauvais pixel, ce qui apprend à l'algorithme à chercher les mauvaises personnes.
Table identifiant → (audience, étape) en dur, et un identifiant inconnu est ignoré et signalé, jamais rangé au hasard. Le sens de l'erreur compte : un prospect manquant ralentit la campagne, un prospect mal rangé l'abîme.
{{fbclid}} n'est pas une macro Meta. Meta ajoute l'identifiant de clic tout seul. Écrite à la main, elle n'est jamais remplacée, arrive en toutes lettres sur la page, et le pixel la recopie dans un cookie qui vit 90 jours.
Les seules macros que Meta remplace : {{ad.id}}, {{adset.id}}, {{campaign.id}}, {{ad.name}}, {{adset.name}}, {{campaign.name}}, {{placement}}, {{site_source_name}}.
Et le filtre doit décoder l'URL en boucle. La même macro revient encodée, souvent plusieurs fois au fil des redirections :
{{fbclid}} → %7B%7Bfbclid%7D%7D → %257B%257B… → %2525252525257B…
Chercher {{ en clair laisse passer toutes les variantes encodées.
Il fait croire à la plateforme qu'elle a de quoi travailler. Trois cas à filtrer :
Elle s'est refermée trois fois le même jour, à trois endroits différents du système.
Les outils de rendez-vous créent le compte rendu dès que l'appel est programmé, vide, et le commercial le remplit après coup. Entre les deux, un test naïf conclut à tort :
| Test naïf | Ce qu'il conclut | La vérité |
|---|---|---|
all(motif == "NO_SHOW") sur une tâche vide | « il est venu » → envoie un Show | on ne sait pas encore |
if not completed → NoShow | « il est absent » → marque absent | on ne sait pas encore |
| statut « réservé » toujours actif | « prépare-le » → email de préparation | l'appel a déjà eu lieu |
Toujours vérifier que le compte rendu porte QUELQUE CHOSE (un motif ou un résultat) avant de trancher. Sinon on attend le tour suivant.
Coût réel de cette erreur : 13 rendez-vous ont reçu à la fois un « Show » et un « NoShow », soit près d'un tiers des Show envoyés. Meta garde le premier reçu, donc c'est le faux qui est resté.
Une réservation compte le jour où le prospect a pris son créneau. Un appel compte le jour où il a lieu. Ne jamais dater un évènement du moment où le cron l'a découvert.
Corollaire pour les libellés de colonnes : « Calls réservés » se lit comme « réservations du jour » alors que la colonne compte les appels qui ont lieu ce jour-là. Un appel pris lundi pour jeudi compte le jeudi. Nommer les colonnes sans ambiguïté.
Conséquence directe : ne jamais envoyer une version pauvre d'un évènement en espérant l'enrichir plus tard. Si un Purchase part sans montant, le Purchase chiffré qui arrive une heure après est jeté.
C'est pour ça qu'un webhook temps réel ne doit jamais envoyer l'évènement de vente : seul le système qui connaît le montant l'envoie.
La plateforme réclame prix et devise sur les évènements de réservation, en promettant du ROAS. C'est un piège de reporting. Quelqu'un qui réserve un appel n'a rien payé. Inventer une valeur estimée revient à déclarer du chiffre d'affaires qui n'existe pas, et le retour sur dépenses affiché devient illisible.
La valeur va sur l'achat, et nulle part ailleurs.
Une fonction serverless (Cloudflare, Lambda, Vercel) n'a aucune mémoire entre deux appels. Si elle lit « les évènements des 3 derniers jours » et les envoie, elle renvoie les mêmes à chaque invocation.
Vécu : un webhook appelé plusieurs fois par jour envoyait 99 évènements à chaque fois, tous déjà partis. Le pixel affichait 1013 réservations par jour pour une réalité de 16, avec un trafic parfaitement stable. Les conversions n'étaient pas doublées (même event_id, la plateforme déduplique), mais les statistiques devenaient illisibles, au point de faire soupçonner l'ancienne agence à tort.
Tout envoyeur sans état a besoin de son propre journal, partagé et durable, avec la même clé que les autres envoyeurs. Et deux garde-fous dans le sens qui protège les conversions : ne noter qu'après un envoi réussi, et en cas de journal illisible, renvoyer plutôt que de perdre.
Un volume de conversions qui décolle alors que le trafic ne bouge pas. Toujours comparer les deux courbes avant d'aller chercher un coupable ailleurs.
Meta refuse de modifier url_tags sur une annonce ou un créatif existant. L'API répond success: true et ne change rien. Ni sur l'annonce, ni sur le créatif. On croit avoir corrigé, on n'a rien fait.
La seule voie : créer un nouveau créatif et le rattacher.
1. Lire le créatif ACTUEL en entier
object_story_spec + asset_feed_spec
+ degrees_of_freedom_spec + url_tags
2. Nettoyer ce qui doit l'être (paramètres, lien de destination)
3. Retirer image_url (lecture seule) et tout réglage déprécié
standard_enhancements fait échouer la création
4. POST vers /adcreatives avec la spec complète
5. POST sur l'annonce : creative = {creative_id: <nouveau>}
6. ATTENDRE, puis vérifier
Cloner depuis le seul identifiant de publication perd les variantes. Un créatif dynamique porte plusieurs textes, titres et descriptions. Un clone simplifié les efface, et l'annonce perd son optimisation. Toujours recopier le créatif entier, et comparer le nombre de variantes avant et après, annonce par annonce.
La propagation n'est pas immédiate. J'ai corrigé 40 annonces, vérifié « 40/40 propres », puis enchaîné sur un second correctif en relisant l'état — qui affichait encore l'ancien créatif. J'ai donc recopié les anciens paramètres et réintroduit le bug sur 14 annonces. Ma vérification suivante est passée trop tôt et n'a rien vu.
Vérifier après propagation, en comparant chaque créatif à celui qu'on attend, pas en relisant un champ qui peut être en cache. Boucler avec attente jusqu'à ce que l'identifiant servi soit celui attendu.
Remplacer un créatif remet l'annonce en revue et peut relancer la phase d'apprentissage. Avant d'y aller, chercher si l'opération a déjà été faite récemment sur ce compte sans casse : c'est le meilleur indicateur. Et sauvegarder tous les anciens identifiants de créatif, le retour arrière tient alors en une commande.
Le score sur 10 n'est pas exposé par l'API, il n'existe que dans l'interface. Mais il est produit par la composition de ce qu'on envoie, qui elle se mesure.
| Champ | Difficulté | Remarque |
|---|---|---|
| email, téléphone, prénom, nom | facile | doivent être à 100 %, sinon chercher pourquoi |
| identifiant client, pays, ville | facile | le pays se déduit de l'indicatif téléphonique |
| identifiant de navigateur | moyen | dépend du cookie du visiteur |
| identifiant de clic | moyen | le vrai levier : macros et périmés le plombent |
| adresse IP + navigateur | le gros levier serveur | voir ci-dessous |
Le problème : les évènements partent après coup, depuis un cron qui n'a jamais vu le visiteur. Et l'outil de rendez-vous n'expose ni l'IP ni le navigateur.
La solution est un pont par le cookie de navigateur, le seul identifiant que le navigateur du visiteur et l'outil de rendez-vous partagent :
Le visiteur arrive sur la page de réservation
→ son navigateur envoie SON COOKIE au serveur
→ le serveur y ajoute l'IP et le navigateur qu'IL voit
→ on stocke la paire, courte durée
Plus tard, à l'envoi de la conversion
→ on retrouve l'IP par le cookie du rendez-vous
Deux règles de sécurité, non négociables :
CF-Connecting-IP et équivalents), jamais dans ce que le navigateur envoie. Sinon n'importe qui en déclare une fausse.Ces deux champs ne se hachent pas, Meta les veut en clair.
L'identifiant de connexion réseau social (+9 %) suppose un bouton « se connecter avec » dans le funnel, juste avant la réservation. La friction coûte plus que le gain.
Code postal, date de naissance, région (+8 % chacun) : les demander dans le formulaire fait chuter le taux de réservation bien plus que ça ne rapporte.
Une fenêtre de 7 jours qui chevauche un changement de système mélange deux sources et affiche des volumes absurdes. Vu : 708 réservations affichées pour une réalité de 40 par jour.
Avant toute lecture, savoir depuis quand le système actuel tourne, et borner la période là-dessus.
Un dashboard qui compte en heure locale et une plateforme qui compte en UTC ne tomberont jamais d'accord sur une journée isolée. Mesuré : 5 % des réservations changent de jour selon le référentiel. Sur une semaine ça s'annule. Ne pas chasser ce fantôme.
Une réponse HTTP 200 ne veut pas dire que tout a été accepté. La réponse porte un nombre d'évènements reçus. S'il est inférieur à ce qu'on a envoyé, ne rien marquer comme envoyé : le tour suivant réessaiera, et la déduplication par identifiant évite les doublons. Sinon les refusés disparaissent en silence.
Une chaîne publicitaire ne s'arrête pas au pixel. Le statut affiché dans le CRM pilote les emails, et un statut faux envoie le mauvais message à un vrai client.
Ce dernier point compte : sur 7 écarts trouvés, il a fallu ouvrir chaque cas pour distinguer les vrais bugs de mes propres approximations.
Le marché suit le lead, pas le montant. Si le prospect est entré par le funnel A, sa vente est du marché A quel que soit le prix signé. Un seuil de prix se périme au premier changement de grille tarifaire, et personne ne s'en aperçoit.
Rapprocher une vente de son lead demande une cascade : email, puis téléphone (sur les derniers chiffres, pour ignorer les indicatifs), puis nom normalisé. Le nom est indispensable : les formulaires de vente internes ne demandent souvent pas l'email.
Rester honnête sur les angles morts plutôt que de les taire :