API Prosperian
API HTTP publique de Prosperian. Elle permet de piloter le flux outbound de bout en bout — sourcer des prospects → créer une séquence → y attacher une liste → l'activer — depuis n'importe quel client HTTP.
- Base URL :
https://app.prosperian.co - Format : JSON (requêtes et réponses). Les routes sont en
POST, sauf les lectures marquéesGET(par exempleGET /api/enrichment/list-coverage). - Spec machine-lisible :
openapi.yaml(OpenAPI 3.1, importable dans Postman / Swagger / pour générer un client). - MCP : la même surface est exposée comme serveur MCP — voir Utilisation via MCP. Deux façons de s'y authentifier : une clé API, ou un connecteur OAuth 2.1 branché depuis claude.ai — voir Connecteur claude.ai (OAuth 2.1).
Première requête
Une lecture sans effet de bord, pour vérifier votre clé en moins d'une minute :
export PROSPERIAN_API_KEY="psk_live_xxx" # votre clé, jamais dans un fichier versionné
curl -s https://app.prosperian.co/api/copilot/stats/list \
-H "X-API-Key: $PROSPERIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"limit":1}'
Réponse attendue :
{ "campaigns": [], "limit": 1 }
Un campaigns vide prouve déjà que la clé est acceptée : l'organisation n'a simplement pas
encore de campagne. Un 401 veut dire clé absente, invalide ou révoquée (voir
Authentification).
Même vérification côté MCP : installez le serveur (voir Utilisation via MCP)
puis appelez l'outil list_campaigns.
Identifiants et champs à connaître (aucun n'est renommé : la table dit lequel lire où) :
| Notion | Nom dans l'API HTTP | Nom dans les outils MCP |
|---|---|---|
| Campagne / séquence | sequence_id (parfois id) | sequence_id |
| Organisation | organizationId (corps) | déduite de la clé |
| Succès d'une écriture | success: true (ou ok: true) | même champ que la route |
Sommaire
- Première requête
- Authentification
- Obtenir une clé API
- Endpoints
- Enrichissement
- Gestion des erreurs
- Rate limiting
- Notifications sortantes (webhooks)
- Compatibilité
- Utilisation via MCP
Authentification
Chaque requête doit porter votre clé API dans le header X-API-Key :
X-API-Key: psk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Une clé est scopée à une seule organisation. Elle ne peut accéder qu'aux ressources de
cette organisation : viser explicitement une autre organisation renvoie 403.
| Cas | Réponse |
|---|---|
Header X-API-Key absent ou clé invalide / révoquée | 401 { "error": "Clé API invalide" } |
| Clé valide, mais le compte qui l'a créée est désactivé | 401 { "error": "Clé API invalide" } |
| Clé valide mais organisation ciblée ≠ organisation de la clé | 403 { "error": "Clé API non autorisée pour cette organisation" } |
Désactiver un membre coupe aussi ses clés. Une clé reste rattachée au compte qui l'a créée : si ce compte est désactivé, la clé cesse d'être acceptée, sans avoir été révoquée — même
401, même corps. C'est la même règle que pour une session. Pour qu'un accès survive au départ de son auteur, recréez la clé depuis un compte actif.
⚠️ Demander une RESSOURCE d'une autre organisation ne rend pas
403, mais404. Sur les trois routes de statistiques filtrables par séquence —get_campaign_stats,get_activity_report,get_objections— unsequence_idqui appartient à quelqu'un d'autre reçoit exactement la même réponse qu'unsequence_idqui n'existe nulle part : même statut, même corps. C'est délibéré, et ce n'est pas de la coquetterie : un refus dont la forme change selon ce qu'il protège apprend au porteur d'une clé quels identifiants sont réels ailleurs. Ne branchez donc aucune logique sur la différence — il n'y en a pas.
Le serveur MCP accepte une seconde famille de credentials. Un connecteur branché depuis claude.ai présente un jeton d'accès OAuth (
pmo_at_…) au lieu d'une clé API. Ce jeton ne vaut que sur/api/mcp: les endpoints HTTP décrits ci-dessous restent enX-API-Key. Voir Connecteur claude.ai (OAuth 2.1).
Obtenir une clé API
- Dans l'application : Paramètres → API et développeurs.
- Créez une clé — réservé aux administrateurs de l'organisation (depuis le 2026-10-05) :
une clé donne un accès machine à toute l'organisation. Un membre voit et révoque seulement
les clés qu'il a créées ; celles créées avant cette date continuent de fonctionner, et un
administrateur les voit toutes. Le secret complet (
psk_live_…) n'est affiché qu'une seule fois à la création — copiez-le et stockez-le en lieu sûr. L'app n'en conserve qu'un hash et les 4 derniers caractères (pour l'affichage). - Pour révoquer une clé, supprimez-la depuis la même page. La révocation est immédiate.
- Désactiver le compte qui a créé une clé la coupe également — voir Authentification.
Les endpoints de gestion des clés (
/api/api-keys) sont réservés à l'interface (auth par session) et ne font pas partie de l'API publique.
Endpoints
POST /api/sourcing/prospects
Lance une recherche de prospects à partir d'une description en langage naturel et de filtres optionnels. La recherche s'exécute en asynchrone ; les prospects trouvés alimentent une liste.
Paramètres (corps JSON)
| Champ | Type | Requis | Description |
|---|---|---|---|
criteria.description | string | ✅ | Description en langage naturel des prospects recherchés |
criteria.industry | string | — | Filtre secteur |
criteria.companySize | string | — | Filtre taille d'entreprise |
criteria.location | string | — | Filtre localisation |
criteria.revenue | string | — | Filtre chiffre d'affaires |
criteria.technologies | string | — | Filtre technologies |
criteria.keywords | string | — | Mots-clés additionnels |
listName | string | — | Nom de la liste créée (auto-généré si absent) |
userContext.organizationId | string | — | Si fourni, doit correspondre à l'org de la clé |
Exemple
curl -X POST https://app.prosperian.co/api/sourcing/prospects \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"criteria": {
"description": "Fondateurs de fintech à Londres",
"industry": "Financial Services",
"location": "London, United Kingdom"
},
"listName": "Fintech London"
}'
Réponse 200
{
"success": true,
"message": "Recherche de prospects lancée avec succès pour la liste \"Fintech London\"",
"webhookResponse": {},
"criteria": "Fondateurs de fintech à Londres",
"listName": "Fintech London"
}
400 si criteria.description est absent.
Recherche payante, sur demande — source: "database" (2026-10-08)
Sans source, rien ne change : la recherche ci-dessus reste immédiate, gratuite et
asynchrone (voir l'avis de migration).
Avec "source": "database", la même route source la liste dans la requête, la rend
directement, et consomme des crédits. La valeur publique ne nomme pas de fournisseur : la
source de données peut changer sans que le contrat change.
Ce chemin est ouvert organisation par organisation : tant qu'il ne l'est pas pour la vôtre,
il rend 403 source_non_activee sans rien lire ni débiter.
| Champ | Type | Requis | Description |
|---|---|---|---|
source | string | ✅ | "database" |
max_credits | integer | ✅ | Plafond DUR de crédits. Le volume est réduit au plus grand nombre de prospects dont le coût maximal tient dessous |
idempotency_key | string | ✅ hors dry_run | 1 à 200 caractères. Couvre la création de la liste ET la facturation (voir plus bas) |
dry_run | boolean | — | true : estimation seule — rien n'est écrit ni débité, la clé n'est pas requise |
count | integer | — | Prospects souhaités (défaut 10), réduit pour tenir sous max_credits |
criteria.description | string | ✅ | Lue par un modèle et traduite en filtres |
criteria.industry | string | — | Secteurs séparés par des virgules. Prime sur la description |
criteria.companySize | string | — | Tranches 11-50, 1000+, séparées par ;. Prime sur la description |
criteria.location | string | — | Lieux du siège (Lyon, France), séparés par ;. Prime sur la description |
listName | string | — | Nom de la liste créée |
Les filtres explicites priment. Sur un axe où vous avez écrit industry, companySize ou
location, ce que le modèle a lu dans la description est écarté — et listé. Le modèle ne
complète que les axes laissés vides (postes, niveaux hiérarchiques, et les axes d'entreprise non
renseignés). Il faut au final un axe personne (poste ou niveau) et un axe entreprise
(secteur, lieu ou taille), sinon 422 filtres_invalides avec missing_axes.
Toute réponse décrit la traduction, succès comme refus de filtres :
normalized_filters:job_titles,seniorities,industries,company_locations,company_sizes(tranches au formatmin,max,min,= ouverte) — ce qui a VRAIMENT été cherché ;rejected_constraints:[{ field, value, reason }], oùreasonvautremplace_par_filtre_explicite,non_pris_en_charge(chiffre d'affaires, technologies, mots-clés),nom_entreprise(sociétés citées : les comptes nommés passent par l'agent de l'application),illisibleoutrop_de_valeurs;translation_version: la même phrase peut être lue autrement d'une version à l'autre.description_criteria(exécution seulement, absent si rien n'a été lu) : critères que le sourcing a tirés de la description et appliqués —filters(posés à la recherche),requirements(exigences comme « ayant des clients en Chine », vérifiées sur la fiche puis le site de chaque entreprise : seules les entreprises confirmées sont livrées ;companies_confirmed,companies_excluded_contradicted,companies_excluded_unconfirmed),company_locations(lieu des entreprises lu dans la phrase) etextraction_unavailable. Une contrainte appliquée ainsi est retirée derejected_constraintsdans la réponse d'exécution.
Exemple — d'abord l'aperçu, puis l'exécution
curl -X POST https://app.prosperian.co/api/sourcing/prospects \
-H "X-API-Key: psk_live_xxx" -H "Content-Type: application/json" \
-d '{
"source": "database",
"dry_run": true,
"max_credits": 60,
"count": 30,
"criteria": { "description": "CTO de fintech", "location": "Lyon, France" }
}'
{
"success": true,
"dry_run": true,
"source": "database",
"statut": "apercu",
"requested_count": 30,
"count": 30,
"max_credits": 60,
"estimated_max_credits": 60,
"normalized_filters": {
"job_titles": ["CTO"],
"seniorities": [],
"industries": ["fintech"],
"company_locations": ["Lyon, France"],
"company_sizes": []
},
"rejected_constraints": [],
"translation_version": "find-leads-2026-10-08.1"
}
Rejouez ensuite le même corps sans dry_run et avec une idempotency_key :
{
"success": true,
"source": "database",
"statut": "termine",
"demande_id": "6f1c…",
"idempotency_key": "lyon-cto-2026-10-08",
"list_id": "9b2e…",
"list_name": "Prospects 2026-10-08",
"count": 30,
"prospects_added": 30,
"credits_charged": 58,
"credits_remaining": 942,
"normalized_filters": { "…": "…" },
"rejected_constraints": [],
"translation_version": "find-leads-2026-10-08.1",
"replay": false,
"exclusions": { "deja_en_liste": 0, "sans_identite": 0, "liste_noire": 0 }
}
Exclusions (depuis le 2026-10-09). Une recherche payante ne livre JAMAIS un prospect dont un
identifiant connu (URL LinkedIn ou email) figure déjà dans une liste de prospects non archivée
de votre organisation, ni dans votre liste noire, ni une personne sans LinkedIn ni email (on ne
peut pas garantir qu'elle est neuve). Ces personnes ne sont pas facturées ; exclusions les
compte. La marge de la source sert à les remplacer ; s'il n'en reste pas assez, statut: partiel. Une
demande antérieure au 2026-10-09, rejouée par sa clé, n'a pas de champ exclusions : le lire comme zéro.
Archiver une liste rend ses prospects de nouveau éligibles. Limite : un prospect connu de votre
liste noire par son seul email, rendu sans email par la source, n'est pas reconnu ici ; il est
arrêté à l'ajout en séquence, une fois enrichi.
Commande complète, puis statut (prérequis : recherche payante activée pour l'organisation, crédits disponibles) :
curl -s -X POST https://app.prosperian.co/api/sourcing/prospects \
-H "Authorization: Bearer $PROSPERIAN_API_KEY" -H "Content-Type: application/json" \
-d '{"source":"database","criteria":{"description":"CTO de SaaS B2B à Lyon"},"count":30,"max_credits":60,"idempotency_key":"lyon-cto-2026-10-09"}'
curl -s "https://app.prosperian.co/api/sourcing/prospects?idempotency_key=lyon-cto-2026-10-09" \
-H "Authorization: Bearer $PROSPERIAN_API_KEY"
estimated_max_credits est un maximum : il est réservé avant la recherche, puis la part non
consommée est rendue. credits_charged est ce qui a réellement été débité.
statut : termine — la liste porte le volume financé ; partiel — elle en porte moins
(moins de prospects trouvés, ou une source interrompue en route) : la liste est livrée telle
quelle et seuls les prospects livrés sont débités ; en_cours (202) — un essai précédent de
la même clé tourne encore ; apercu — dry_run.
Idempotence. La clé est unique par organisation et couvre la liste ET la facturation :
- un nouvel essai avec la même clé et le même corps — y compris après une coupure réseau
survenue APRÈS l'écriture mais AVANT la réponse — rend le résultat enregistré (même
list_id,replay: true) sans rien refacturer ; pendant l'exécution, il rend202 en_cours; - la même clé avec un autre corps rend
409 cle_idempotence_reutilisee; - une demande interrompue sans résultat enregistré rend
500 etat_inconnuà chaque essai : ne la relancez pas sous la même clé, contactez le support avec sondemande_id; - seule exception :
503 sourcing_indisponible, entièrement remboursé, est réessayable sous la même clé — le nouvel essai repart de zéro et ne débite qu'une fois ; rejoué avecreplay: true, il signale que l'essai précédent n'est pas encore clos : réessayer un peu plus tard.
Pour une nouvelle recherche, même identique, utilisez une nouvelle clé.
Statut consultable : GET /api/sourcing/prospects?idempotency_key=<clé> rend le même corps
que l'essai (ou 404 demande_introuvable), sans jamais rien relancer.
Codes — chaque refus porte code, error (français), message: { fr, en } et
credits_outcome (aucun_debit, rembourses ou inchanges). Les erreurs internes de la source
de données ne sont jamais renvoyées.
| Statut | code | Sens |
|---|---|---|
| 400 | source_inconnue | source différent de database |
| 400 | description_requise | criteria.description absente |
| 400 | max_credits_requis | max_credits absent |
| 400 | max_credits_invalide | pas un entier > 0 |
| 400 | nombre_invalide | count pas un entier > 0 |
| 400 | cle_idempotence_requise | clé absente hors dry_run |
| 400 | cle_idempotence_invalide | clé vide ou de plus de 200 caractères |
| 402 | credits_epuises | solde insuffisant ; rien n'est débité |
| 403 | source_non_activee | recherche payante pas encore ouverte pour l'organisation |
| 403 | approbation_credits_requise | l'organisation valide les dépenses de ses membres : votre autorisation ne couvre pas la réservation (cout_estime, autorisation_restante) ; rien n'est débité ; la même clé se relance une fois l'autorisation augmentée (depuis MCP : demandez-la par request_credits, suivez-la par get_credit_requests) |
| 404 | demande_introuvable | (GET) aucune demande pour cette clé |
| 409 | cle_idempotence_reutilisee | clé déjà utilisée pour une autre demande |
| 422 | filtres_invalides | ni axe personne, ni axe entreprise (missing_axes) |
| 422 | max_credits_insuffisant | le plus petit sourcing coûte plus que max_credits (minimum_credits) |
| 422 | aucun_resultat | aucun prospect ne correspond ; la réservation est rendue. Avec raison: "tous_exclus" : des candidats existaient, mais tous étaient déjà dans vos listes, sans identité ou en liste noire (voir exclusions). Avec raison: "exigences_non_confirmees" : des exigences de la description ont été lues (voir description_criteria) et aucune entreprise ne les confirme sur sa fiche ou son site. Sans raison, avec description_criteria.requirements.companies_confirmed > 0 : des entreprises confirment l'exigence, mais aucun de leurs contacts n'a passé la vérification LinkedIn (parti ailleurs ou hors cible). Aussi quand la base ne trouve aucune entreprise pour ces critères |
| 500 | etat_inconnu | demande interrompue sans résultat enregistré |
| 502 | traduction_echouee | la description n'a pas pu être lue ; rien n'est débité, réessayez ou explicitez |
| 503 | fournisseur_indisponible | la source de données n'a pas répondu ; la réservation est rendue |
| 503 | droits_illisibles | vos droits de dépense n'ont pas pu être lus ; rien n'est débité, réessayez avec la même clé (retry_after_seconds) |
| 503 | sourcing_indisponible | votre liste noire (raison: liste_noire_illisible, rien débité) ou vos listes (deja_en_liste_illisible, réservation rendue) n'ont pas pu être lues ; réessayez avec la même clé |
⚠️ Les crédits partent au premier appel. Contrairement à enrich_list, cette recherche ne
passe pas par une validation dans l'application : c'est max_credits qui borne la dépense.
Commencez toujours par dry_run: true.
⚠️ Les demandes payantes comptent dans le plafond quotidien de sourcing de l'agent autonome : celui-ci est calculé sur la capacité d'appel de vos commerciaux, que les deux partagent.
POST /api/copilot/create-campaign
Crée une séquence outbound (campagne) avec une suite d'étapes ordonnées. La séquence est créée
au statut draft — utilisez update-status pour l'activer.
Paramètres (corps JSON)
| Champ | Type | Requis | Description |
|---|---|---|---|
name | string | ✅ | Nom de la campagne |
sequence | array | — | Étapes ordonnées (voir ci-dessous) |
sequence[].type | string | ✅ | Type d'étape : mail_send, linkedin_message, logic_wait, … |
sequence[].subject | string | — | Objet (pour les étapes email) |
sequence[].message | string | — | Corps du message |
sequence[].delay | number | — | Délai en jours (pour les étapes d'attente) |
description | string | — | Description de la campagne |
organization_id | string | — | Doit correspondre à l'org de la clé si fourni |
Exemple
curl -X POST https://app.prosperian.co/api/copilot/create-campaign \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Cold email fintech",
"sequence": [
{ "type": "mail_send", "subject": "Quick question", "message": "Bonjour {{first_name}}, ..." },
{ "type": "logic_wait", "delay": 3 },
{ "type": "mail_send", "subject": "Re: Quick question", "message": "Petite relance ..." }
]
}'
Réponse 200
{
"success": true,
"campaignId": "a1b2c3d4-0000-0000-0000-000000000000",
"stepsCount": 3,
"outreachSynced": true,
"message": "Campagne créée"
}
POST /api/sequences/attach-prospects
Attache tous les prospects d'une liste à une séquence : déduplique, crée les prospects manquants, génère les tâches d'outreach et synchronise vers le système d'envoi. Les prospects présents dans la blacklist de l'organisation sont ignorés.
Opération potentiellement longue (jusqu'à 300 s pour de grandes listes).
Paramètres (corps JSON)
| Champ | Type | Requis | Description |
|---|---|---|---|
sequence_id | string | ✅ | UUID de la séquence cible |
prospect_list_id | string | ✅ | UUID de la liste de prospects à attacher |
Exemple
curl -X POST https://app.prosperian.co/api/sequences/attach-prospects \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"sequence_id": "a1b2c3d4-0000-0000-0000-000000000000",
"prospect_list_id": "e5f6a7b8-0000-0000-0000-000000000000"
}'
Réponse 200
{
"success": true,
"added": 87,
"tasksCreated": 174,
"attachedInThisCall": 87,
"totalInSequence": 87,
"blacklisted": 3,
"enrolled_skipped": 0,
"locked_skipped": 0,
"outreach": { "native": true, "mode_envoi": "natif", "enrolled": 87 },
"motif_enrolement": {
"kind": "filtre",
"resolus": 90,
"enroles": 87,
"liste_tronquee": false,
"ventilation": { "liste_noire": 0, "deja_demarche": 2, "canal_absent": 1, "doublon": 0 }
},
"ai_import": { "ran": false, "keys": [], "generated": 0, "skipped_existing": 0 }
}
Champs additionnels possibles : blacklist_skips[] ({ id, label, reason }), prospect_errors[],
message.
⚠️
totalInSequenceest déprécié — utilisezattachedInThisCall. Les deux portent la même valeur (le volume rattaché par CET appel), maistotalInSequenceporte l'ancienne sémantique « total de la séquence », qui est fausse : ce total est recalculé séparément, depuis les enrôlements réels.
⚠️
outreachn'est présent que sur le chemin natif. Ce bloc était documenté ici avec un champpushedque le code n'émet jamais — la forme réelle est{ native, provider, enrolled }, oùenrolledest identique àmotif_enrolement.enroles.
Lire motif_enrolement — POURQUOI ce nombre de leads, et pas un autre
Les cinq portes de sortie en 200 le portent : subset vide, liste vide ou introuvable, tout
blacklisté, tout déjà en campagne, tout sous verrou 90 j, plus le chemin nominal. Le champ est
une union discriminée par kind ; la liste des valeurs et leur sens exact vivent dans
openapi.yaml (schéma MotifEnrolement).
⚠️ Absent ≠ zéro. Un client qui ne trouve pas ce champ doit dire qu'il ne sait pas, jamais afficher une ventilation à zéro : la réponse peut venir d'un déploiement antérieur.
⚠️ enroles = INSÉRÉ, mesuré. L'écriture est synchrone : le nombre est celui que la base a
réellement enregistré, pas celui qu'on a proposé. Un fait accompli, pas une intention.
⚠️ Un reliquat peut rester en vol. Sur une grosse liste, l'écriture s'arrête avant la limite
de durée de l'appel et le reste est repris ensuite : non_tentes compte ces lignes, et
non_tentes - non_confies seront enrôlées d'elles-mêmes en quelques minutes. non_confies est le
sous-ensemble que personne ne reprendra tout seul.
⚠️ non_confies a DEUX causes, et arret_pause est le discriminant. Soit le reliquat a été
REFUSÉ à la reprise — il faut alors relancer. Soit la séquence a été MISE EN PAUSE pendant
l'écriture, et rien n'a alors été proposé du tout : non_confies vaut TOUT non_tentes, aucun
refus n'a eu lieu, et le geste attendu est de réactiver la séquence. Lire arret_pause pour les
distinguer ; la spécification OpenAPI porte le détail du champ.
⚠️ Ces nombres ne s'additionnent pas. resolus est ce que le FILTRE a vu : il n'inclut
aucune des trois exclusions amont (blacklisted, enrolled_skipped, locked_skipped), retirées
avant. L'égalité exacte porte trois termes, pas un :
somme(ventilation) + exclus_pool + non_tentes = resolus - enroles — une ligne tombe dans un seul
compartiment, et deux causes vivent HORS ventilation parce qu'elles ne sont pas des verdicts sur
le prospect (aucun expéditeur sain ; écriture arrêtée avant la fin). ventilation compte
désormais cinq compartiments (deja_enrole s'y est ajouté).
⚠️ Une exception : quand deja_enroles_illisible vaut true, l'égalité devient un
minorant — la lecture des déjà-enrôlés ayant échoué, ces lignes sont écartées par la
contrainte d'unicité sans tomber dans aucun terme. Sur ce chemin,
ventilation.liste_noire vaut toujours 0 : l'exclusion est faite en amont et rapportée par
blacklisted.
Réponse 502 — les leads ont bien été rattachés, mais l'ordre d'enrôlement n'a pas pu
être transmis au moteur d'envoi : personne ne recevra rien. motif_enrolement.kind vaut
envoi_impossible. added, count et tasksCreated restent renseignés (le rattachement, lui,
a eu lieu). Rejouable tel quel : la création de tâches est dédupliquée, relancer ne double
aucune ligne.
Réponse 503 — la liste noire n'a pas pu être lue. Aucun lead n'a été rattaché et aucune
tâche créée : continuer sans elle risquerait de recontacter des personnes qui ont demandé à ne
plus l'être. motif_enrolement.kind vaut liste_noire_illisible. Réessayez.
POST /api/sequences/update-status
Passe une séquence en active (démarre les envois) ou en paused (les arrête), et synchronise
l'état vers le système d'outreach.
Pré-requis pour l'activation : un abonnement outreach actif et au moins un profil expéditeur configuré pour l'organisation. À défaut, l'endpoint renvoie une erreur.
Opération potentiellement longue (jusqu'à 300 s) : l'activation lit la liste cible en paginant (jusqu'à 20 000 lignes), charge la liste noire paginée, puis émet les ordres d'enrôlement par lots.
Paramètres (corps JSON)
| Champ | Type | Requis | Description |
|---|---|---|---|
sequence_id | string | ✅ | UUID de la séquence |
status | string | ✅ | active ou paused |
read_fingerprint | string | — | Empreinte des steps TELS QU'AFFICHÉS à la relecture. Arme une vérification de concurrence optimiste sur active seulement : si le texte a changé depuis, la route refuse en 409 version_changee et n'active RIEN. L'omettre ne satisfait pas la garde en silence — elle ne s'arme pas, et vous gardez le comportement d'avant. |
Exemple
curl -X POST https://app.prosperian.co/api/sequences/update-status \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"sequence_id": "a1b2c3d4-0000-0000-0000-000000000000",
"status": "active"
}'
Réponse 200
{
"ok": true,
"status": "active",
"mode_envoi": "natif",
"native": true,
"enroll_items": 87,
"motif_enrolement": {
"kind": "filtre",
"resolus": 90,
"enroles": 87,
"liste_tronquee": false,
"ventilation": { "liste_noire": 1, "deja_demarche": 1, "canal_absent": 1, "doublon": 0 }
}
}
Champs additionnels possibles : post_engager_ingestion.
⚠️ Cet exemple documentait deux champs de synchronisation que le code n'émet plus, les colonnes correspondantes ayant été supprimées.
enroll_items est le nombre de lignes réellement insérées en base par cet appel ; il vaut 0
sur toute sortie dont le motif_enrolement.kind n'est pas filtre. Même réserve de lecture que
pour attach-prospects : absent ≠ zéro. Contrairement au chemin d'attachement, ventilation.liste_noire peut ici être non nul —
l'activation filtre la liste noire elle-même.
Réponse 500 (panne d'enrôlement) — quatre motifs : envoi_impossible, lecture_impossible,
liste_noire_illisible, verrou_illisible (cf. Verrou de 90 jours). La séquence est rendue à son statut d'avant et les enrôlements
réveillés par l'appel sont rendormis : rien n'a été activé, rien n'a été envoyé. L'appel est
atomique du point de vue du client et se rejoue tel quel. Le corps porte ok: false,
status (le statut restauré, pas active), enroll_items: 0 et motif_enrolement.
⚠️ Une activation qui n'enrôle personne pour une raison normale (brouillon sans canal, liste
vide, tout filtré) reste un 200 : y rendre un 5xx déclencherait des tempêtes de reprise sur le
geste le plus courant du produit. Lisez motif_enrolement, pas seulement le code HTTP.
Verrou de 90 jours
Un lead sorti d'une campagne il y a moins de 90 jours (terminé, arrêté, répondu, bounce…) n'est pas recontacté. Le verrou s'applique à tout enrôlement : rattachement, activation, reprise, vidange des inscriptions différées — que la case « exclure les déjà contactés » soit cochée ou non. Avant le 2026-10-14, seul le rattachement l'appliquait : une activation relisait toute la liste cible et enrôlait aussi les leads verrouillés.
Exceptions. Par séquence, un humain peut recontacter quand même les leads dont l'historique
des 90 derniers jours ne porte QUE des catégories cochées : réponses positives, réponses de refus,
bounces (recontactés sans e-mail, par téléphone ou LinkedIn), invitations LinkedIn non
acceptées. Une désinscription, un arrêt de conformité ou une raison inconnue n'est jamais repris.
Les exceptions se règlent dans le builder (bloc « Verrou de 90 jours ») ; aucune API ne les
écrit, et une écriture de steps par une clé API ou le MCP les conserve telles quelles. Pour
reprendre des leads après le lancement : cochez l'exception, puis enregistrez la séquence active.
Lecture. Dans motif_enrolement (kind: "filtre") :
ventilation.verrou_90j— lignes écartées par le verrou. Entre danssomme(ventilation): l'invariantsomme(ventilation) + exclus_pool + non_tentes = resolus − enrolesest inchangé.verrou— la politique appliquée, en lecture seule :mode,derogations,classement(calcule/non_calcule/indisponible), et quandclassementvautcalcule, les écartés par catégorie (categories),non_qualifie,deroges,deroges_ecartes_ailleurs.verrou_derogations_non_appliquees: true— les exceptions n'ont pas pu être appliquées (panne ou échéance du classement) : les leads concernés restent écartés ; relancez plus tard.
Un champ absent veut dire « non appliqué par ce chemin » (ancien serveur, interrupteur hors
on), jamais zéro mesuré.
Écartés en partie :
{
"kind": "filtre",
"resolus": 305,
"enroles": 91,
"repris": 0,
"ventilation": { "liste_noire": 0, "deja_enrole": 0, "deja_demarche": 0, "canal_absent": 0, "doublon": 0, "verrou_90j": 214 },
"verrou": {
"mode": "on",
"derogations": { "reponse_positive": false, "reponse_refus": false, "bounce": false, "invitation_non_acceptee": false },
"classement": "non_calcule",
"deroges": 0,
"deroges_ecartes_ailleurs": 0
},
"liste_tronquee": false, "arret_pause": false, "deja_enroles_illisible": false,
"exclus_pool": 0, "non_tentes": 0, "non_confies": 0
}
Tous écartés : une activation manuelle qui n'enrôle personne est refusée en 409
sequence_sans_prospect (la séquence garde son statut) ; le message nomme le verrou et le corps
porte le même motif_enrolement avec ventilation.verrou_90j.
Verrou illisible : 500, code: "verrou_illisible", motif_enrolement: { "kind": "verrou_illisible" }. Personne n'a été enrôlé, la séquence garde son statut réel ; l'appel se
rejoue tel quel (code transitoire). Côté rattachement : 503, même code, aucun lead rattaché ;
si la relecture du verrou échoue APRÈS le rattachement, 502 avec le même code — les leads sont
rattachés (added, count renseignés), personne n'est enrôlé, et le rejeu est idempotent.
Exceptions non appliquées : 200, "verrou_derogations_non_appliquees": true, verrou.classement: "indisponible".
Rattachement : locked_skipped compte les leads écartés par le verrou (présent sur toutes les
réponses 200 qui ont lu le verrou) ; locked_skipped_sans_prospect en isole ceux qui n'avaient
pas encore de prospect — l'activation ne les voit pas.
Enrichissement
Enrichit des contacts ou des listes en email et/ou téléphone. Consomme des crédits de
l'organisation (email ≈ 1, téléphone ≈ 10 par contact résolu) ; une réponse 402 est renvoyée si
le solde est insuffisant. Pour une clé API, organizationId est optionnel (déduit de la clé).
Modèle asynchrone : certaines résolutions ne sont pas immédiates. Une réponse avec
pending: true (ou des compteurs from…API) signifie qu'une partie est en cours côté fournisseur.
Récupérez les résultats avec les endpoints de réconciliation (sync-pending-item,
sync-pending-list, pull-list-item).
| Endpoint | Rôle |
|---|---|
POST /api/enrichment/manual | Créer + enrichir un nouveau contact (nom + société) |
POST /api/enrichment/single | Enrichir un prospect existant (prospectId) |
POST /api/enrichment/prospect-list-item | Enrichir une ligne de liste précise (itemId) |
POST /api/enrichment/list | Enrichir tous les prospects d'une liste existante |
POST /api/enrichment/csv | Créer une liste depuis des prospects fournis (≤ 1000) et l'enrichir |
GET /api/enrichment/list-coverage | Couverture et bilan d'enrichissement d'une liste (lecture seule) |
POST /api/enrichment/sync-pending-item | Réconcilier une ligne en attente |
POST /api/enrichment/sync-pending-list | Réconcilier les lignes en attente d'une liste |
POST /api/enrichment/pull-list-item | Forcer le suivi de l'enrichissement d'une ligne |
Enrichir un contact — POST /api/enrichment/manual
| Champ | Type | Requis | Description |
|---|---|---|---|
first_name | string | ✅ | Prénom |
last_name | string | ✅ | Nom |
enrichmentTypes | object | ✅ | { email?: boolean, phone?: boolean } — au moins un true |
company_name | string | — | Société |
company_domain | string | — | Domaine (ex. stripe.com) — améliore le taux de match |
linkedin_url | string | — | URL LinkedIn |
itemId | string | — | Reprise d'un contact déjà enrichi par /manual (voir plus bas) |
organizationId | string | — | Optionnel avec une clé API |
curl -X POST https://app.prosperian.co/api/enrichment/manual \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Jane",
"last_name": "Doe",
"company_domain": "stripe.com",
"enrichmentTypes": { "email": true }
}'
{
"success": true,
"origine": "recherche",
"data": { "email": "jane@stripe.com" },
"itemId": "…",
"listId": "…",
"pending": false,
"creditsDeducted": 1,
"message": "Données enrichies immédiatement.",
"telephoneSansLinkedin": false,
"channels": {
"email": { "status": "found", "origine": "recherche" },
"phone": { "status": "not_requested" },
"linkedin": { "status": "not_requested" }
},
"linkedin": { "url": null, "candidate": null }
}
channels donne le statut de chaque canal : found, already_present, not_found (recherché
sans résultat), not_searched (non recherché — reason: "missing_linkedin" : le téléphone exige
l'URL LinkedIn), error (recherche interrompue — panne fournisseur, reason: "provider_error", aucun crédit
débité : à réessayer),
pending (en attente du fournisseur : poller sync-pending-item), needs_review (canal
linkedin seulement : URL à confirmer, voir plus bas), not_requested.
telephoneSansLinkedin (historique, gardé pour compatibilité) vaut true quand le téléphone
demandé n'a pas pu être cherché faute d'URL LinkedIn. Préférer
channels.phone.reason === "missing_linkedin".
Champ optionnel itemId : reprend un contact déjà enrichi par /manual (même organisation)
pour compléter ses canaux manquants au lieu de créer une seconde fiche ; les valeurs déjà
obtenues sont rendues already_present et ne sont ni recherchées ni refacturées.
URL LinkedIn cherchée avant le téléphone (fonction activable, désactivée par défaut). Quand le
téléphone est demandé sans linkedin_url, Prosperian cherche l'URL — base interne, puis
recherche par nom + société — avant de lancer la recherche du numéro. La réponse
porte toujours channels.linkedin (not_requested quand la recherche n'a pas lieu,
already_present quand une URL est fournie) et linkedin: { url, candidate } :
-
channels.linkedin.status = "found",origine: "recherche"(ou"base": base interne) : URL trouvée par Prosperian (le profil porte le nom du contact) ; elle est enregistrée sur le contact et sert au téléphone ; -
"needs_review": URL probable mais le profil ne correspond pas exactement au nom (homonyme possible). Elle n'est pas utilisée :linkedin.candidatela donne, et le téléphone vautnot_searched/identity_mismatchtant qu'elle n'est pas confirmée ; -
"not_found": aucune URL ; le téléphone vaut alorsnot_found/missing_linkedin. -
"pending",origine: "recherche": une AUTRE requête tient déjà ce contact (recherche d'URL puis remise du téléphone) ; le téléphone vaut aussipendinget la réponse portepending: true. C'est l'autre requête qui conclut : pollersync-pending-itemjusqu'àpending: false(au plus le bail de 2 minutes ; une requête abandonnée ne fait jamais sonder sans fin). Rien n'est débité pour ce canal.
Les autres raisons possibles : missing_name, missing_company (recherche impossible),
provider_unavailable (fournisseur indisponible, aucun crédit débité), quota (plafond
quotidien de recherches de l'organisation atteint).
Compatibilité de channels.phone (fonction activée seulement) : une recherche d'URL conclue sans
résultat rend désormais le téléphone not_found / missing_linkedin (avant : not_searched /
missing_linkedin, qui reste la valeur quand la recherche n'a pas eu lieu) ; trois raisons
s'ajoutent — identity_mismatch (avec not_searched), provider_unavailable et quota (avec
error, origine: "recherche" : c'est la recherche d'URL qui a échoué, pas celle du numéro). Un
client qui valide l'énumération des raisons doit les accepter.
Crédits : le coût maximal des canaux demandés (1 par email, 10 par téléphone) est débité
AVANT la recherche — un solde insuffisant refuse avant toute dépense — puis le trop-perçu est
rendu. creditsDeducted est le débit NET ; s'il dépasse le coût des données obtenues, c'est
qu'un remboursement n'a pas abouti (journalisé côté serveur).
La recherche a lieu pour /manual, /single et /prospect-list-item, dont les réponses
portent channels (statut par canal, dont linkedin) et linkedin (url utilisée,
candidate à confirmer). L'outil MCP enrich_contact ne cherche pas l'URL. Pour une liste
(/list, /csv), l'URL est cherchée en arrière-plan : results.linkedinEnFile compte les
lignes dont le téléphone attend l'URL (elles restent pending jusqu'à la conclusion).
Confirmer ou écarter une URL LinkedIn — POST /api/enrichment/linkedin-review
| Champ | Type | Requis | Description |
|---|---|---|---|
itemId | string | ✅ | itemId rendu par /manual |
decision | string | ✅ | confirm ou reject |
candidateUrl | string | ✅ | La candidate affichée (linkedin.candidate) |
organizationId | string | — | Optionnel avec une clé API |
confirm enregistre la candidate comme URL du contact, sans lancer de recherche : rappeler
ensuite /manual avec le même itemId, linkedin_url = la candidate et le seul téléphone
demandé (10 crédits, débités seulement si un numéro est trouvé). reject l'écarte et elle ne
sera jamais reproposée sur cette fiche. Une décision déjà prise (ou une URL posée entre-temps)
rend 409 decision_deja_prise ; confirmer un profil déjà associé à un autre contact de la
liste rend 409 doublon_dans_la_liste.
Enrichir une liste — POST /api/enrichment/list
| Champ | Type | Requis | Description |
|---|---|---|---|
prospectListId | string | ✅ | Liste à enrichir |
enrichmentTypes | object | ✅ | { email?: boolean, phone?: boolean } |
organizationId | string | — | Optionnel avec une clé API |
approval_action_id | string | ✅ avec une clé API | Demande validée par un humain : sans elle, la route refuse (403) sans rien débiter. Voir la validation des outils d'écriture plus bas |
verifyEmails | boolean | — | Vérifier les emails (gratuit, en arrière-plan) — sans effet si la vérification n'est pas ouverte pour l'organisation (GET /api/enrichment/options) ; aussi accepté par /api/enrichment/csv |
curl -X POST https://app.prosperian.co/api/enrichment/list \
-H "X-API-Key: psk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "prospectListId": "…", "enrichmentTypes": { "email": true, "phone": false }, "approval_action_id": "…" }'
{
"success": true,
"message": "Enrichissement lancé pour 120 prospects",
"creditsDeducted": 110,
"runId": "…",
"results": {
"total": 120,
"success": 110,
"errors": 10,
"fromDatabase": 30,
"fromAsyncAPI": 80,
"fromSearch": 0,
"skippedNoLinkedin": 0,
"linkedinEnFile": 0,
"alreadyHadEmail": 12,
"alreadyHadPhone": 0
}
}
Crédits : un solde insuffisant rend 402 (required, available) avant toute dépense ; une
organisation qui valide les dépenses de ses membres peut rendre 403 approbation_credits_requise
(voir le tableau des refus de la recherche payante, même forme) ;
creditsDeducted donne le débit du lancement. Le suivi se fait comme décrit dans la section
suivante.
Ces compteurs décrivent le lancement, pas le résultat final : un compteur from… autre que
fromDatabase non nul signifie que des résultats arrivent encore. alreadyHadEmail /
alreadyHadPhone comptent les lignes qui avaient déjà une valeur valide avant ce lancement.
Suivre un enrichissement jusqu'au bilan
- Lancer :
POST /api/enrichment/list(liste existante) ouPOST /api/enrichment/csv(nouvelle liste, son identifiant est danslistId). - Réconcilier :
POST /api/enrichment/sync-pending-listavec{ "prospectListId": "…" }, toutes les 4 à 5 secondes, tant quependingRemainingest supérieur à 0. Arrêtez-vous àpendingRemaining: 0(la réponse porte alorssummary) ou après une douzaine de minutes ; une réponse500(code: lecture_impossible) se réessaie telle quelle. Un appel travaille une quarantaine de secondes au plus : sur une longue file,pendingRemainingbaisse d'appel en appel. - Lire le bilan :
GET /api/enrichment/list-coverage?prospectListId=…rendsummary, sans aucun effet : par canal,found(valeur valide),pending(recherche en cours) etnotFound(sans valeur, y compris les lignes jamais recherchées).
{
"total": 120,
"withSignal": 118,
"missingSignal": 2,
"summary": {
"total": 120,
"email": { "found": 96, "notFound": 24, "pending": 0 },
"phone": { "found": 0, "notFound": 120, "pending": 0 },
"rowsPendingAny": 0,
"rowsPending": { "email": 0, "phone": 0, "either": 0 }
}
}
Migration : pour savoir combien de contacts ont obtenu un email, lisez
summary(email.found) plutôt que les compteursfrom…du lancement, qui ne voient pas les résultats arrivés ensuite. Une liste inexistante et une liste d'une autre organisation rendent la même réponse404(code: liste_introuvable), surlist-coveragecomme sursync-pending-list(qui rendait403pour une autre organisation). LemessagedePOST /api/enrichment/listdit désormais « lancé » et non « terminé » : ne vous fiez pas à son texte.
Importer et enrichir des prospects — POST /api/enrichment/csv
Le corps est du JSON, pas un fichier CSV : c'est le client (l'écran d'enrichissement, ou votre script) qui lit le fichier et envoie ses lignes. Crée une nouvelle liste à chaque appel.
| Champ | Type | Requis | Description |
|---|---|---|---|
listName | string | ✅ | Nom de la liste créée |
enrichmentTypes | object | ✅ | { email?: boolean, phone?: boolean } — au moins un true |
prospects | object[] | ✅ | ≤ 1000 lignes : first_name, last_name, email, phone, title, linkedin_url, location, company_name, company_domain, company_website, company_linkedin_url — un email ou un téléphone fourni n'est pas recherché (ni facturé) à l'enrichissement |
prospects[].custom_fields | object | — | Variables {{clé}} des séquences : clé ^[a-z_][a-z0-9_]*$ (≤ 64), valeur texte (coupée à 2000) |
customVariableDefinitions | object[] | — | { key, label } : libellé affiché pour chaque clé (coupé à 128 caractères) |
sourceColumns | string[] | — | En-têtes (≤ 256 car.) des colonnes du fichier à conserver (≤ 200), dans l'ordre du fichier |
prospects[].source_row | string[] | — | Valeurs brutes de ces colonnes, alignées sur sourceColumns ; coupées au-delà de 32 768 car. |
- Variables custom : au plus 30 clés distinctes par requête, comptées sur l'UNION de
toutes les lignes (une ligne avec
k1…k20et une autre aveck11…k31font 31 clés). Une clé au-delà, invalide ou réservée (first_name,ai_*…) n'est pas écrite ; elle est listée danscustomFields.droppedde la réponse. Une clé dont aucune valeur n'est du texte (nombre, booléen, objet) y figure aussi, avec la raisoninvalid.droppeddétaille au plus 100 clés ;droppedTotaldonne le nombre exact. - Colonnes conservées : rendues à la fin du téléchargement de la liste
(
GET /api/export/prospect-list/{id}, depuis l'application : cette route n'accepte pas de clé API), sous leur en-tête d'origine (« (fichier) » ajouté si l'en-tête reprend une colonne native). Comme toutes les cellules de l'export, une valeur commençant par=,+,-ou@reçoit une apostrophe (protection contre l'injection de formules dans un tableur). - Un champ facultatif à
nullvaut absent. - Taille : le corps est limité à environ 4 Mo par la plateforme (réponse
413en texte brut). - Le MCP
import_prospectspasse par l'import simple de liste, qui ne conserve pas ces colonnes.
Réponse (champs ajoutés au format d'enrichList) :
{
"success": true,
"listId": "…",
"sourceColumns": { "kept": 2, "truncatedValues": 0 },
"import": { "rejected": 0, "emailsIgnored": 0, "phonesIgnored": 0 },
"customFields": {
"retained": ["segment"],
"dropped": [{ "key": "k31", "reason": "limit" }],
"droppedTotal": 1,
"variablesRegistered": true
}
}
variablesRegistered: false : les valeurs sont écrites sur les contacts, mais les clés n'ont
pas pu être ajoutées à la liste des variables des séquences. sourceColumns.truncatedValues :
nombre de valeurs conservées coupées à 32 768 caractères. import : lignes écartées, toutes raisons
(doublons, nom manquant, valeur trop longue…) et cellules email / téléphone vidées parce qu'elles n'en étaient pas
(téléphone sans aucun chiffre). Le titre et la localisation sont coupés à 255 caractères ; le site
et le LinkedIn de l'entreprise sont conservés avec le contact (rendus à l'export) et le site sert
de domaine à la recherche d'email quand company_domain manque. Corps mal formé : 400 avec
code = json_invalide (JSON illisible) ou requete_invalide (forme), avant toute écriture.
Les autres endpoints (
single,prospect-list-item,list-coverage,sync-pending-*,pull-list-item) suivent les mêmes conventions d'auth et de crédits. Voiropenapi.yamlpour les schémas request/response complets.
Gestion des erreurs
Les erreurs renvoient un statut HTTP 4xx/5xx et un corps JSON :
{ "error": "Message lisible", "code": "code_stable", "ref": "a1b2c3d4" }
codeest la valeur à lire pour décider quoi faire ;errorest une phrase pour un humain, en français, qui peut changer.- Une erreur inattendue côté serveur rend
500aveccode: "erreur_interne"et unerefcourte : donnez cetterefau support pour qu'il retrouve l'incident. Depuis le 2026-10-05, une erreur inattendue ne renvoie plus le texte brut de l'exception (base de données, réseau) danserrornidetails: il reste dans nos journaux. - Un refus métier (
4xx) garde un message lisible ; sur un envoi de messagerie refusé par le fournisseur (code: "envoi_refuse"), c'est le motif du fournisseur, déjà assaini. Une panne du fournisseur rend502,code: "fournisseur_indisponible", sans son texte.
| Code | Signification |
|---|---|
400 | Requête invalide (champ requis manquant, statut non supporté). Sur get_prospect : code: 'identifiant_requis' quand ni prospect_id ni prospect_list_item_id n'est fourni |
401 | Clé API absente, invalide ou révoquée — ou clé dont le compte créateur a été désactivé (voir Authentification) |
402 | Crédits d'enrichissement insuffisants ({ error, required, available }) |
403 | Clé non autorisée pour l'organisation ciblée |
404 | Ressource introuvable (prospect, item ou liste). Deux codes stables depuis ce lot : prospect_introuvable sur get_prospect, et sequence_hors_organisation sur get_activity_report et get_objections quand le sequence_id demandé n'appartient pas à l'organisation de la clé. ⚠️ get_campaign_stats refuse au même statut mais SANS code — { "error": "Séquence introuvable" } — alors testez le statut, pas le code. Sur les trois, le refus est identique que l'identifiant soit inconnu ou qu'il appartienne à une autre organisation (voir l'avertissement de la section Authentification). ⚠️ Ce dernier est un 404 délibéré, pas des zéros : rendre 0 partout rendrait « cette séquence n'est pas à vous » indiscernable de « cette séquence n'a pas eu d'activité » — l'ambiguïté exacte qui a laissé le filtre par séquence cassé sans que personne ne le voie |
409 | Conflit. Quatre émetteurs sur update-status, discriminés par code — c'est lui qui décide s'il faut réessayer, jamais le statut HTTP : activation_deja_en_cours (une autre activation tourne ; le verrou est pris avant toute écriture ⇒ réessayer est sûr), organisation_en_migration (gel temporaire ⇒ réessayer plus tard), no_healthy_sending_account (aucune boîte connectée ⇒ ne pas réessayer, un humain doit en connecter une), sequence_sans_prospect (activation manuelle qui n'enrôle personne : statut d'avant rétabli ⇒ ne pas réessayer avant d'avoir ajouté des prospects joignables). Sur les routes d'enrichissement : enrichissement déjà en cours pour l'organisation |
500 | Erreur interne (code: "erreur_interne" et une ref à donner au support ; la cause reste au journal serveur, plus dans details). Sur update-status, recouvre aussi la panne d'enrôlement : la séquence est rendue à son statut d'avant, l'appel se rejoue tel quel |
502 | sourcing/prospects, analyze-disc, analyze-pre-rdv : le webhook du moteur d'automatisation a répondu en erreur. code: 'webhook_tiers_en_erreur', plus status_tiers qui porte le code HTTP rendu par le tiers. Notre configuration est bonne ; c'est l'appel sortant qui a échoué. Le corps du tiers n'est PAS relayé (il finissait dans details) : il reste au journal serveur. Rejouable |
503 | sourcing/prospects, analyze-disc, analyze-pre-rdv : le webhook n'a pas pu être JOINT (réseau, DNS, délai). code: 'webhook_injoignable'. La configuration est bonne, le tiers n'a même pas été atteint — réessayable. ⚠️ Même statut que la ligne ci-dessous : c'est le code qui distingue, jamais le statut |
503 | sourcing/prospects, analyze-disc, analyze-pre-rdv : la fonctionnalité n'est pas configurée sur ce déploiement (aucune variable de webhook posée). code: 'webhook_non_configure'. ⚠️ Ni le nom de la variable ni l'URL ne sortent dans la réponse — seul le journal serveur les nomme. Ne pas réessayer : un administrateur doit configurer le déploiement |
502 | attach-prospects : leads rattachés, mais l'ordre d'enrôlement n'a pas pu être transmis au moteur d'envoi. Rejouable (création de tâches dédupliquée) |
503 | attach-prospects : la liste noire n'a pas pu être lue — rien n'a été rattaché, plutôt que de risquer de recontacter des personnes qui l'ont refusé. Réessayez |
400 | POST /api/prospect-lists/import : code: 'lignes_invalides' — une ligne n'a pas la forme { lineNo, values } (lineNo entier de 1 à 2 147 483 647, unique dans le lot, values objet colonne → texte ou null — lu comme vide —, sans octet NUL). Même statut, code: 'mapping_invalide' : mapping n'est pas un objet champ → nom de colonne (texte). index donne la position de la ligne fautive dans rows (0 = première), le message la cite en rows[index]. Vérifié AVANT toute écriture : aucune liste ni aucun run n'est créé. L'outil MCP import_prospects prend des lignes PLATES et les convertit lui-même |
404 | import_prospects (POST /api/prospect-lists/import) : code: 'run_introuvable' (aussi quand la propriété du run est refusée d'emblée ; le 503 d'une propriété illisible porte preparation_import_indisponible) (le runId est celui d'un autre utilisateur, d'une autre organisation, ou d'un run qui n'est pas un import) ou code: 'liste_introuvable' (le prospectListId n'existe pas dans votre organisation). Rien n'a été écrit |
409 | POST /api/prospect-lists/import : code: 'run_liste_differente' — ce runId est déjà rattaché à une autre liste (renvoyée dans prospectListId, sur la route HTTP seulement) ; code: 'run_liste_indisponible' — la liste de ce runId a été supprimée ou archivée ; code: 'liste_indisponible' — le prospectListId visé est archivé ou désactivé. Ne pas réessayer à l'identique : relancer avec un NOUVEAU runId. Rien n'a été écrit. Via l'outil MCP import_prospects, dont le runId est dérivé des entrées : run_liste_differente ne peut pas se produire, et run_liste_indisponible est relancé automatiquement, jusqu'à 3 fois, sous un runId dérivé des entrées et du numéro de relance (un rappel identique retombe sur la même relance) ; au-delà, l'erreur est rendue telle quelle |
503 | import_prospects : code: 'preparation_import_indisponible' — la liste de prospects ou d'entreprises n'a pas pu être préparée. Réessayable à l'identique : le même runId reprend la même liste, sans doublon. Aucun prospect n'a été écrit |
⚠️ Sur les routes adossées au moteur d'automatisation, c'est le
codequi discrimine, pas le statut HTTP — même convention que le409ci-dessus. Les quatre codes sontwebhook_non_configure(503, ne pas réessayer : un administrateur doit configurer le déploiement),webhook_injoignable(503, réessayable : le webhook n'a pas pu être joint — réseau, DNS, délai dépassé),webhook_tiers_en_erreur(502) eterreur_serveur(500) ; ils sont stables. Le champerrorreste servi pour les journaux, mais un client doit traduire lecode: la phrase du serveur est en français, et l'afficher telle quelle est le « repli mort » décrit plus haut.
⚠️ Un
200ne veut pas dire « des leads sont partis ». Sur les deux endpoints d'enrôlement, une sortie à zéro pour une raison normale (brouillon sans canal, liste vide, tout blacklisté, tout déjà en campagne) reste un200— c'estmotif_enrolementqui porte le pourquoi. Les5xxsont réservés aux pannes techniques, celles qui méritent une reprise.
Rate limiting
Restez raisonnable sur le volume et la fréquence des appels, en particulier pour
attach-prospects (opération lourde). Un usage manifestement abusif peut être restreint
sans préavis.
Les endpoints OAuth sont limités. Au-delà du débit autorisé, ils rendent 429 ;
patientez avant de réessayer.
Notifications sortantes (webhooks)
L'action d'automatisation « Notifier une application » envoie un POST JSON vers l'URL
de votre choix (Zapier, Make, n8n, ou votre serveur) à chaque déclenchement de la règle.
Elle est réservée aux administrateurs de l'organisation.
Requête
| En-tête | Valeur |
|---|---|
Content-Type | application/json |
User-Agent | Prosperian-Webhooks/1 |
Idempotency-Key | identifiant stable de CETTE action dans CETTE exécution (identique à id du corps) |
X-Prosperian-Timestamp | horodatage de l'envoi, en secondes Unix |
X-Prosperian-Signature | sha256=<hex>, ou deux valeurs séparées par , pendant une rotation |
Corps (version 1) :
{
"type": "automation.webhook",
"version": 1,
"id": "evt_123:auto_456:prospect_789:a-0",
"created_at": "2026-09-30T08:00:00.000Z",
"test": false,
"data": {
"trigger": "lead.interested",
"automation_id": "auto_456",
"run_id": "run_abc",
"prospect": {
"id": "prospect_789",
"first_name": "Camille", "last_name": "Exemple",
"email": "camille.exemple@example.com", "phone": null, "title": "Directrice commerciale",
"company_name": "Exemple SAS", "company_website": "https://example.com", "company_domain": "example.com"
},
"event": { "disposition": "rdv" }
},
"custom": { "source": "prosperian" }
}
- Les clés de
prospectsont toujours présentes ; une valeur inconnue vautnull. customporte les « données supplémentaires » saisies dans l'action (nullsinon). Les variables{{prospect.email}},{{event.disposition}}… y sont remplacées : seule dans une valeur, une variable garde son type (nombre, booléen,null) ; dans un texte, elle y est insérée.test: truesignale un envoi d'EXEMPLE (bouton « Envoyer un test à cette URL ») : données fictives, à ignorer par vos traitements.
Vérifier la signature
La chaîne signée est <X-Prosperian-Timestamp>.<corps BRUT>, en HMAC-SHA256 avec le secret de
signature de votre organisation (Réglages → Automatisations), encodée en hexadécimal
minuscule. Vérifiez sur les octets reçus, avant tout JSON.parse : un corps re-sérialisé
ne signe plus pareil. Refusez un horodatage à plus de 5 minutes de votre horloge.
Node.js :
import { createHmac, timingSafeEqual } from 'node:crypto'
function signatureValide(secret, horodatage, corpsBrut, enTete) {
if (Math.abs(Date.now() / 1000 - Number(horodatage)) > 300) return false
const attendue = createHmac('sha256', secret).update(`${horodatage}.${corpsBrut}`).digest('hex')
return enTete.split(',').map((v) => v.trim().replace(/^sha256=/, '')).some((recue) =>
recue.length === attendue.length && timingSafeEqual(Buffer.from(recue), Buffer.from(attendue)))
}
Python :
import hashlib, hmac, time
def signature_valide(secret: str, horodatage: str, corps_brut: bytes, en_tete: str) -> bool:
if abs(time.time() - int(horodatage)) > 300:
return False
attendue = hmac.new(secret.encode(), horodatage.encode() + b"." + corps_brut, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v.strip().removeprefix("sha256="), attendue) for v in en_tete.split(","))
Valeur de contrôle : secret whsec_test, horodatage 1767225600, corps {"a":1} ⇒
sha256=6d204545ff126a339c00513d93603299c984074ce1331c3537c3f6c976b49019.
La vérification est recommandée, pas imposée : une URL à jeton secret (celle de Zapier, Make ou n8n) suffit à les authentifier. L'URL est chiffrée chez nous et n'est jamais affichée en entier (seul son hôte apparaît).
Réponse, nouvelles tentatives, doublons
- Répondez 2xx en moins de 10 secondes. Traitez en arrière-plan si besoin.
- 3 tentatives au plus (après 1 s puis 4 s) sur une réponse 5xx, un délai dépassé ou une
erreur réseau ; une réponse 429 est réessayée en respectant
Retry-After(10 s au plus). Aucune nouvelle tentative sur 4xx, redirection (3xx, jamais suivie) ou certificat invalide. - La livraison est au moins une fois : dédoublonnez sur
Idempotency-Key(ouid). Un « Rejouer l'étape » depuis l'historique renvoie le même corps et la même clé. - Une notification en échec n'empêche pas les actions suivantes de la règle ; elle apparaît dans l'historique des exécutions avec sa cause.
Rotation du secret
« Remplacer le secret » en crée un nouveau. Pendant 24 heures, chaque requête porte les
deux signatures (sha256=<nouvelle>, sha256=<ancienne>) : acceptez toute valeur qui
correspond, et mettez vos récepteurs à jour pendant cette fenêtre.
Tester depuis un poste de développement
Les adresses locales ou privées (localhost, 127.0.0.1, 192.168.x.x…) et les ports autres
que 80/443 sont refusés. Exposez votre serveur local par un tunnel public (par exemple
ngrok http 3000 ou cloudflared tunnel --url http://localhost:3000) et utilisez l'URL
HTTPS qu'il fournit.
Zapier, Make, n8n
- Zapier : déclencheur « Webhooks by Zapier » → « Catch Hook » ; collez l'URL fournie.
- Make : module « Webhooks » → « Custom webhook » ; collez l'adresse générée.
- n8n : nœud « Webhook », méthode
POST, URL de production (celle de test ne répond que pendant l'écoute).
Envoyez ensuite un test depuis l'action : le service reçoit un corps d'exemple (test: true)
dont il déduit les champs.
Compatibilité
Cette section existe parce qu'elle manquait, et que son absence rendait indéfendable toute affirmation du type « ce changement est additif, donc sûr ». Elle dit ce sur quoi vous pouvez vous appuyer.
Ce qui NE casse pas, et que vous devez tolérer
Les changements suivants peuvent survenir sans préavis et ne sont pas considérés comme cassants. Un client qui s'y casse est un client trop strict :
- l'ajout d'un champ dans un objet de réponse ;
- l'ajout d'une valeur à une énumération, quand le champ décrit un état observé
(
couverture.motif, par exemple) ; - l'ajout d'un paramètre optionnel en entrée ;
- l'ajout d'un endpoint ou d'un outil MCP.
⚠️ Ignorez les champs inconnus. Un client généré avec additionalProperties: false, un
zod.strict() ou un DisallowUnknownFields en Go cassera sur un ajout parfaitement légitime.
Le schéma openapi.yaml ne déclare pas additionalProperties: false, et
c'est délibéré.
⚠️ Un champ ABSENT ne vaut pas zéro. C'est la règle la plus facile à enfreindre par
inadvertance : votre réponse peut venir d'un déploiement antérieur à l'ajout du champ. Quand un
champ porte une information de couverture — linkedin.couverture.etat, motif_enrolement — son
absence veut dire « je ne sais pas », et un client doit le dire plutôt que d'afficher 0.
Ce qui casse, et comment c'est annoncé
Sont cassants : le retrait ou le renommage d'un champ, le changement de type d'un champ, et le changement de SENS d'une valeur à nom constant.
⚠️ Cette dernière catégorie est la plus dangereuse, et elle s'est déjà produite. La
ventilation par liste de get_activity_report est devenue une PARTITION : aucun champ n'a
changé de nom ni de type, mais le total d'une liste nommée a BAISSÉ. Un client qui suivait la
série temporelle a vu une chute qui n'existait pas. Une rupture de valeurs ne se voit ni dans un
schéma, ni dans un test de forme — elle ne peut être qu'annoncée.
Engagement : une rupture est annoncée dans ce document, dans la section de l'endpoint concerné, avec la date et la nature du changement.
Ruptures annoncées
2026-10-15 — Outils MCP : plus aucun nom de fournisseur dans les résultats
Nature : changement des RÉSULTATS et des ERREURS des outils MCP (l'API HTTP n'est pas touchée : ses champs historiques restent renvoyés, dépréciés). Partout dans un résultat :
sourcequi nomme un fournisseur →origine: "recherche"(les valeurs neutres restent :"database", mode de la recherche payante defind_leads,"input","mixed"…) ;providerqui nomme un fournisseur →mode_envoi: "natif"pour le moteur d'envoi, retiré sinon ;- compteurs de recherche nommés (
enrich_list) →fromSearch(leur somme) ; sited'une dépense qui nomme un fournisseur → alias neutre (enrichment/list,company-lists/enrich,sourcing/organization,calls/call), qu'il suffit de recopier dansrequest_credits: l'outil le retraduit ;error/message(et le texte d'une erreur d'outil) qui nommerait un fournisseur → phrase neutre ; uncoded'erreur dans ce cas →service_externe_indisponible;webhookResponse(réponse brute du moteur de sourcing defind_leadsgratuit) n'est plus rendu.
Les données libres (noms de listes, d'entreprises) ne sont jamais réécrites ; seuls les messages du serveur peuvent l'être.
2026-10-15 — Noms neutres pour l'enrichissement de liste, la provenance et le mode d'envoi
Nature : AJOUTS, rien ne casse. Les anciens noms restent servis et acceptés ; ils sortent de cette documentation.
| Ancien | Nouveau | Statut de l'ancien |
|---|---|---|
| chemin historique de l'enrichissement de liste | POST /api/enrichment/list | Servi à l'identique, avec les en-têtes Deprecation: true et Link: </api/enrichment/list>; rel="successor-version" |
source (provenance d'une donnée) | origine : fourni, base, recherche | Toujours renvoyé, déprécié |
provider (moteur d'envoi) | mode_envoi : natif | Toujours renvoyé, déprécié |
alias historique de source de find_leads | "database" | Toujours accepté (même clé d'idempotence : un réessai n'est jamais refacturé) |
Le retrait d'un ancien nom sera une rupture annoncée et datée dans cette section, jamais déduite du seul trafic.
2026-10-14 — Le verrou de 90 jours s'applique aussi à l'activation
Nature : changement de comportement. update-status (active), la reprise et la vidange
des inscriptions différées écartent désormais les leads sortis d'une campagne il y a moins de
90 jours, comme le rattachement le faisait déjà. Déploiement progressif : d'abord en observation
(rien n'est filtré, l'écart est journalisé), puis appliqué.
- Nouveaux champs optionnels :
ventilation.verrou_90j,verrou,verrou_derogations_non_appliquees,locked_skipped_sans_prospect. - Nouveau motif
verrou_illisible(500à l'activation,503au rattachement,502si la relecture échoue après le rattachement), rejouable. - Les anciennes catégories de réponse (valeurs anglaises) ne sont pas reprises par les exceptions.
Ce qu'il faut faire : tolérer ces champs et ce motif ; lire ventilation.verrou_90j avant de
conclure qu'une liste « n'a enrôlé personne ». Voir Verrou de 90 jours.
2026-10-09 — Sourcing : plus jamais un prospect déjà dans vos listes
Nature : changement de comportement, sans changement de forme. Toute recherche (écran,
agent, find_leads payant) écarte désormais les personnes déjà présentes dans une liste de
prospects non archivée de l'organisation, celles de la liste noire, et celles sans LinkedIn ni
email. Elles ne sont ni livrées ni facturées.
- Jour 1 :
count: 30sur « CTO SaaS Lyon » → 30 prospects,exclusions.deja_en_liste: 0. - Jour 2, même recherche, nouvelle clé → uniquement des prospects neufs ; si le marché
s'épuise,
statut: partiel(seuls les livrés sont débités), ouaucun_resultatavecraison: "tous_exclus".
Ce qu'il faut faire : tolérer le champ exclusions et le code sourcing_indisponible (503,
réessayable sous la même clé). Pour qu'une ancienne liste ne bloque plus ses prospects,
archivez-la.
Avis de migration — find_leads : recherche payante sur demande (2026-10-08)
Nature : AJOUT, sans rupture. POST /api/sourcing/prospects et l'outil MCP find_leads
gagnent un chemin payant opt-in (source: "database", voir
la section de l'endpoint). Sans
source, la réponse, son sens et sa gratuité sont inchangés ; un source: null vaut absent.
Fenêtre de support de 90 jours : le contrat par défaut (sans source) est garanti inchangé
au moins jusqu'au 2027-01-06. Tout changement de ce défaut — par exemple faire de la
recherche payante le comportement par défaut — sera annoncé ici, daté, au moins 90 jours avant
de prendre effet. Ce qu'il faut faire d'ici là : rien, sauf si vous voulez la liste rendue dans
la réponse — passez alors source, max_credits et une idempotency_key.
À l'ouverture de la recherche d'URL LinkedIn — POST /api/enrichment/manual : sens de channels.phone
Nature : changement de SENS d'une valeur à nom constant, conditionné au drapeau (OFF au
déploiement de la PR 1a, le 2026-10-07 : rien ne change tant qu'il l'est). Drapeau ouvert pour
une organisation, quand le téléphone est demandé sans URL LinkedIn et que la recherche d'URL a
eu lieu sans résultat, channels.phone vaut not_found / missing_linkedin (avant :
not_searched / missing_linkedin, qui reste la valeur quand la recherche n'a pas eu lieu).
Trois raisons s'ajoutent : identity_mismatch (avec not_searched : une URL attend sa
confirmation), provider_unavailable et quota (avec error, origine: "recherche"). Champs
AJOUTÉS : channels.linkedin, linkedin: { url, candidate }. La date d'ouverture sera ajoutée
ici à l'ouverture du drapeau.
2026-10-06 — POST /api/enrichment/csv : plafond des variables custom appliqué aux valeurs
Nature : changement de comportement. Les prospects[].custom_fields étaient écrits en
totalité alors que seules 30 clés étaient enregistrées comme variables. Désormais, au-delà de
30 clés distinctes sur toute la requête, les clés suivantes ne sont pas écrites ; elles
sont listées dans customFields.dropped (champ AJOUTÉ). Une clé de plus de 64 caractères, ou
__proto__ / constructor / prototype, est refusée de même. Un retour arrière ne restaure
pas les valeurs non écrites. Champs AJOUTÉS : sourceColumns, customFields. Le
téléchargement d'une liste créée avec sourceColumns gagne des colonnes à la fin : un
lecteur positionnel des 31 colonnes historiques n'est pas affecté, un lecteur qui compte les
colonnes l'est. Deux colonnes historiques changent aussi pour les listes importées par CSV
SANS fiche société liée, y compris celles créées avant ce changement : « Entreprise » et
« Domaine entreprise », vides jusqu'ici, reprennent la valeur du fichier. Enfin, deux corps
autrefois acceptés sont refusés en 400 requete_invalide : un prospects[].source_row envoyé
sans sourceColumns, et prospects: null (qui recevait déjà un 400, sans code).
2026-10-06 — Définitions des KPI harmonisées (RDV, Leads, Contactés, taux)
Nature : changement de SENS de valeurs à nom constant, sur plusieurs métriques. Aucun champ
n'est retiré ni renommé ; un seul est AJOUTÉ par endpoint concerné. Les définitions complètes,
une ligne par indicateur, sont dans docs/kpi-definitions.md.
get_activity_report—rdvne compte plus les RDV de prospects supprimés ; les tâches terminées sont datées par leur date de complétion (une édition ultérieure ne les déplace plus) ; champ AJOUTÉnonAttribue(mêmes compteurs qu'une ligneperUser) : RDV sans propriétaire, appels et tâches sans auteur, absent quand il serait nul ou quand le rapport est filtré par membre. La sommeperUser[].rdv + nonAttribue.rdvégale désormais le total.- Colonnes de séquence (
get_campaign_stats,list_campaignset tout lecteur derdv_generes,rdv_show,ca_genere,show_rate,rdv_email|linkedin|phone,rdv_show_*,show_rate_*,taux_rdv_*) — un RDV est attribué à UNE séquence, celle du dernier envoi avant sa prise ; un prospect réenrôlé ne compte plus dans chacune. Les show rates ne portent plus que sur les RDV passés.messages_linkedinexclut les invitations et gestes d'engagement (le taux de réponse LinkedIn monte d'autant). Les valeurs changent au premier recalcul après le déploiement. get_campaign_stats(vue fenêtrée) — « Intéressé » n'accepte plus qu'une réponse ENTRANTE écrite ; les taux des étapes sont rapportés aux prospects actifs sur la fenêtre, champ AJOUTÉfunnel.baseFenetre(funnel.basereste « membres depuis le lancement ») ; la carteappelsPassescompte des appels dédoublonnés, sans les messages vocaux ; le taux d'une carte (buckets[].cards[].rate) sans dénominateur vautnull, plus0. Les taux des étapes (funnel.stages[].rate, largeur de barre) et les colonnes de séquence restent à0sans dénominateur : lire l'opérande avant d'interpréter un0.- Leads (tableau de bord, Copilot
monthly_goal/active_leads, colonneleadsdes séquences) — un Lead est un prospect ni RDV ni client ayant, sur la fenêtre, une réponse positive ÉCRITE (statutqualifie) ou un appel qualifié « à relancer » (statut autre quenon_qualifie). Le drapeauleadseul ne suffit plus.monthly_goal.currentporte désormais sur le MOIS (Paris). - Fenêtres — « 30 derniers jours » couvre 30 dates calendaires (31 avant), en heure de Paris.
2026-10-06 — Codes promo sur la recharge ponctuelle
Routes d'interface (session, administrateurs seulement). Détail : runbook
recharge-credits.md, section « Codes promo ».
GET /api/stripe/checkout-credits-topup?session_id=…peut rendrepaiement: "offert"(session réglée à 0 € par un code promo) etraison: "coupon_non_autorise"(coupon non restreint au produit de recharge : jamais crédité).POST /api/stripe/checkout-credits-topuppeut rendre 503configuration_produitquandSTRIPE_PRODUCT_ID_RECHARGEest invalide ou archivé.
2026-10-05 — Recharge ponctuelle et hausse immédiate du pack de crédits
Routes d'interface (session, administrateurs seulement), pas de l'API publique. Détail et
codes d'erreur : docs/runbooks/recharge-credits.md.
- Nouveau
POST /api/stripe/checkout-credits-topup{ organizationId, quantity }→{ url }d'un paiement unique Stripe ;GET→{ achatOuvert, libelleFiscal };GET ?session_id=cs_…→{ octroi, paiement, raison? }. - Nouveau
GET /api/stripe/credits-subscription/apercu?organizationId&quantity→ montant au prorata et crédits d'une hausse immédiate. POST /api/stripe/credits-subscriptionchange de SENS sans changer de forme. Sansimmediat, la nouvelle quantité n'est plus écrite sur l'abonnement : elle est PROGRAMMÉE pour le prochain cycle. La réponse portequantity= quantité EFFECTIVE (inchangée) etpendingQuantity= quantité programmée. Avecimmediat: true, la hausse est facturée au prorata tout de suite.GET /api/stripe/credits-subscriptiongagnependingQuantityetupgradeImmediateOpen.
2026-10-05 — Facturation réservée aux administrateurs, erreurs sans détail technique
- Achats et abonnement : administrateurs seulement.
POST /api/stripe/checkout,/checkout-subscription,/credits-subscription,/customer,/portaletPOST /api/billing/unsubscriberendent403aveccode: "admin_requis"à un membre. Ces routes sont des routes d'interface (session), pas de l'API publique ; la lecture de l'abonnement crédits (GET /api/stripe/credits-subscription) reste ouverte aux membres. - Le client Stripe ne vient jamais du corps. Un
customerIdenvoyé à/api/stripe/portal,/api/stripe/checkoutou/api/stripe/checkout-subscriptionest refusé :400,code: "customer_id_refuse". - Boîtes d'envoi et domaines aussi (décision du 2026-10-05, après #455) :
/api/stripe/checkout-mailboxeset/api/stripe/checkout-domainsrendent403admin_requisà un membre. Résilier une boîte reste possible pour son propriétaire ou un admin actif de l'organisation. - Les URL de retour d'un paiement restent dans l'application.
successUrletcancelUrlsont ignorées si elles pointent vers un autre site ; le payeur revient sur la page par défaut. POST /api/sourcing/prospectsexige une organisation. Une session sansuserContext.organizationIdrend400,code: "organisation_requise"(une clé API, elle, porte déjà son organisation).- Plus de texte d'exception dans les erreurs : voir Gestion des erreurs.
2026-09-10 — get_objections : la catégorie repondeur fusionne dans nrp
Nature : changement de SENS d'une valeur à nom constant — la catégorie la plus dangereuse
décrite juste au-dessus, et exactement la forme de l'incident perList qui a motivé cette
section. Aucun champ ne change de nom ni de type.
Ce qui change : l'issue d'appel « Messagerie » a été fusionnée dans « NRP » côté produit ;
la catégorie d'objection repondeur, qui la doublait dans la taxonomie du classifieur, suit.
get_objections ne renvoie plus repondeur : ces appels sont désormais comptés sous nrp,
dans categories, dans matrix, et dans les ventilations byCaller / byList.
Ordre de grandeur : repondeur représentait la catégorie la plus fréquente du produit —
3 745 enregistrements classés au moment de la fusion, contre 229 pour nrp. Le saut sur nrp
est donc massif, et il est daté de cette ligne.
⚠️ Les séries temporelles qui traversent cette date ne sont PAS comparables. Un client qui
suit nrp verra une marche ; un client qui suit repondeur verra un zéro. Ni l'un ni l'autre
ne correspond à un changement de comportement des prospects.
Ce qui n'est PAS touché : les données historiques ne sont pas réécrites — repondeur reste
en base sur les enregistrements déjà classés. C'est la LECTURE qui rabat. Un accès direct à la
base (hors de cette API) voit donc toujours l'ancienne valeur.
2026-09-10 — Webhook Power Dialer : call_status ne vaut plus messagerie
Nature : retrait d'une valeur d'énumération d'un contrat sortant.
Ce qui change : le webhook Power Dialer (configuré par organisation dans Réglages ›
Appels) livrait call_status: "messagerie" avec call_status_label: "Messagerie" quand un
commercial qualifiait un appel comme tombé sur répondeur. Cette issue n'existe plus : ces
appels partent désormais en call_status: "nrp", call_status_label: "Sans réponse".
Qui est concerné : les organisations ayant un webhook configuré — 7 sur 120 au moment de la fusion, et 1 573 événements avaient déjà été livrés avec l'ancienne valeur.
⚠️ Un mapping qui branche sur call_status == "messagerie" cesse de se déclencher, sans
erreur. Le remplacement est "nrp", qui recouvre désormais les deux cas.
Événement d'automatisation call.voicemail : plus émis. Il l'était pour messagerie ou
mvn ; mvn reste couvert par call.disposition_set. L'identifiant reste déclaré pour que
les événements historiques restent lisibles dans le journal.
2026-09-25 — import_prospects (MCP) fonctionne enfin ; la route refuse les lignes mal formées
Nature : CORRECTIF. L'outil MCP import_prospects échouait à CHAQUE appel : il transmettait ses lignes plates ({ "Email": "…" }) alors que la route attend { lineNo, values }, et la route plantait en 500 après avoir créé la liste de prospects, sa liste d'entreprises et le run. L'outil convertit désormais ses lignes (numérotées depuis 1 dans l'ordre reçu ; nombres en texte, null en cellule vide), et la route rend 400 lignes_invalides à toute ligne mal formée, avant d'écrire quoi que ce soit.
Qui est concerné : aucun appel MCP d'import n'avait pu réussir, rien n'est donc à migrer. Côté HTTP, un appelant qui envoyait des lignes illisibles (objets plats, cellules non textuelles) recevait un 500 et voyait apparaître une liste vide ; il reçoit désormais un 400 qui désigne la ligne. Une cellule null reste acceptée. ⚠️ Changement de comportement : deux lignes au même lineNo rendent désormais 400 (la seconde était absorbée en silence), et un mapping non textuel rend 400 mapping_invalide.
Deux autres plantages de la même famille sont fermés (import en 500 APRÈS la création des listes) :
- un CSV importé depuis l'écran dont une ligne a plus de cellules que l'en-tête : des cellules en trop VIDES (virgule finale) sont ignorées ; si l'une n'est pas vide, CETTE ligne seule est rejetée (motif
field_mismatch, ses cellules d'origine dans le rapport), car ses valeurs sont probablement décalées ; - un
mappingqui vise un nom hérité (toString,constructor,__proto__) : la colonne est lue comme vide.
2026-09-25 — import_prospects : une liste d'entreprises par liste de prospects
Nature : RUPTURE de comportement, pas seulement un champ ajouté. companyListId est ajouté à
la réponse, et plusieurs cas qui réussissaient ou rendaient un autre statut changent :
| Situation | Avant | Depuis |
|---|---|---|
Liste d'une autre organisation dans prospectListId | 403 « Liste hors de votre organisation » | 404 liste_introuvable |
prospectListId archivée ou désactivée | import accepté | 409 liste_indisponible |
Rejeu d'un runId vers un AUTRE prospectListId | le run était repointé, ses lignes coupées en deux | 409 run_liste_differente |
Rejeu d'un runId dont la liste a été supprimée ou archivée | une nouvelle liste était créée | 409 run_liste_indisponible (l'outil MCP relance seul, jusqu'à 3 fois) |
Rejeu d'un runId avec un autre listName | le nom était relu | ignoré : le run est lié à sa liste |
| Panne à la création de la liste ou du run | 500 | 503 preparation_import_indisponible, réessayable |
| Liste d'entreprises impossible à créer | import SANS entreprise, 200 | 503, AUCUN prospect écrit |
Ce qui change : jusqu'ici, les entreprises de tout import portant une colonne entreprise
allaient dans UNE liste par organisation, « CRM Entreprises ». Elles vont désormais dans
« Entreprises — <nom de la liste de prospects> ». Et une panne de préparation de cette liste
rend désormais 503 sans rien importer, au lieu d'importer les prospects sans entreprise.
Ce qui n'est PAS touché : les listes « CRM Entreprises » existantes restent telles quelles, avec leurs entreprises et les prospects qui y sont liés. La création MANUELLE d'un prospect dans l'application continue de les alimenter.
⚠️ Trois effets de bord à anticiper :
- une intégration qui réimporte chaque jour dans la MÊME liste reconstruit ses entreprises une fois dans la nouvelle liste : le dédoublonnage ne voit pas celles de « CRM Entreprises ». Doublons, et enrichissement refacturable sur ces fiches ;
- un import COMMENCÉ avant ce changement et rejoué après verra ses nouvelles lignes rattachées à « Entreprises — X », ses premières restant liées à « CRM Entreprises » ;
- renommer la liste de prospects ne renomme pas sa liste d'entreprises, et la supprimer laisse sa liste d'entreprises en place.
- un import rejoué APRÈS que sa liste d'entreprises a été archivée ET qu'un autre import a recréé une liste pour la même liste de prospects verse ses lignes restantes dans la nouvelle : l'import est alors réparti sur deux listes d'entreprises (rien n'est perdu).
Dépréciations
get_activity_report sert perUser[].userName, alias de displayName, marqué
@deprecated dans le code.
⚠️ La phrase « retiré à la prochaine version majeure » a longtemps figuré ici, et elle ne
voulait rien dire : cette API n'a aucun schéma de version. Pas de /v1/, pas d'en-tête de
version, pas d'en-têtes Deprecation / Sunset, pas de journal des changements. Le champ
info.version d'openapi.yaml versionne le DOCUMENT, pas le contrat. Promettre un retrait
« à la prochaine majeure » nommait donc un événement qui ne peut pas se produire.
En l'état, userName n'est pas retirable, et le dire vaut mieux que reconduire la promesse.
Le retirer suppose d'abord un mécanisme de version — c'est une dette ouverte, pas un oubli.
Utilisation via MCP
La même surface est exposée comme serveur MCP hébergé (Streamable HTTP) sur /api/mcp
— implémentation dans le code source,
outils dans le code source — pour piloter Prosperian
depuis Claude Code (ou tout client MCP) sans écrire de requêtes HTTP :
find_leads → create_campaign → attach_prospects → start_sequence
| Outil MCP | Endpoint |
|---|---|
find_leads | POST /api/sourcing/prospects |
create_campaign | POST /api/copilot/create-campaign |
attach_prospects | POST /api/sequences/attach-prospects |
start_sequence | POST /api/sequences/update-status |
enrich_contact | POST /api/enrichment/manual |
enrich_list | POST /api/enrichment/list |
import_prospects | POST /api/prospect-lists/import |
⚠️ Trois des sept s'exécutent AU PREMIER APPEL — find_leads, create_campaign et
import_prospects — et les quatre autres passent par la validation humaine décrite juste
en dessous. Aucune des trois n'envoie de message à qui que ce soit, et, dans leur usage par
défaut, aucune ne facture de crédit : c'est ce qui les dispense de décision humaine, pas leur
innocuité en général. ⚠️ Exception, opt-in : find_leads avec source: "database" CONSOMME
des crédits dès le premier appel, borné par max_credits (obligatoire) — commencez par
dry_run: true. Sans idempotency_key, l'outil en dérive une des arguments : rejouer le même
appel ne refacture jamais, et une nouvelle recherche identique demande une nouvelle clé. Un refus
garde, sur une ligne details: {…} sous le message, les champs qui servent à décider
(raison, credits_outcome, credits_charged, demande_id, exclusions, idempotency_key), sur
tout outil dont la route rend ces champs. Pour
import_prospects, le plafond de lignes reste celui de la route (MAX_IMPORT_ROWS) :
l'outil MCP relaie ce refus, il ne le duplique pas.
import_prospects et les entreprises. Si mapping porte company_name, les entreprises du
fichier vont dans la liste d'entreprises de la liste de prospects, nommée
« Entreprises — <nom de la liste> » : créée au premier import, réutilisée ensuite (y compris
pour une liste issue du sourcing). La réponse rend son id dans companyListId (null sans
colonne entreprise). Trois conséquences à connaître :
- Le dédoublonnage des entreprises est borné à cette liste. Une entreprise présente dans deux imports vers deux listes différentes existe en deux fiches — et un enrichissement facture chaque fiche.
- Un
runIdest lié à sa liste. Le rejouer (même corps) reprend la même liste de prospects et la même liste d'entreprises, sans rien dupliquer ; au rejeu,listNameest ignoré. Le rejouer vers une autre liste rend409 run_liste_differente. - L'import échoue fermé. Si la liste d'entreprises ne peut pas être préparée, la réponse est
503 preparation_import_indisponibleet aucun prospect n'est écrit — plutôt qu'un succès sans rattachement entreprise, qu'un rejeu ne réparerait plus.
⚠️ attach_prospects, enrich_contact, enrich_list et start_sequence (branche
« activer ») ne s'exécutent PAS au premier appel. Ils passent par proposeOrExecute
(le code source) : le premier appel
crée une demande de validation en attente et n'exécute rien. Il faut
un second appel portant un approval_action_id approuvé par un humain pour que
executeApprovedAction fasse le travail — en appelant les fonctions internes, pas l'endpoint
HTTP listé ci-dessus. La colonne « Endpoint » indique donc la surface équivalente, pas ce que
l'outil déclenche. Seule exception : start_sequence avec status: 'paused' appelle
directement POST /api/sequences/update-status.
Ce que rend chacun des deux appels.
status | Champs | Ce qu'il faut en faire |
|---|---|---|
pending_approval | approval_action_id, approval_url, message | rien n'a été exécuté ni facturé ; ouvrir approval_url, faire valider, puis rappeler l'outil |
executed | message, data (optionnel) | l'action a eu lieu ; data porte son résultat |
error | message | rien n'a été exécuté |
⚠️ approval_url est un lien, et il remplace une consigne. La réponse disait « Ouvrez
l'espace Agent › À valider » : un client MCP ne peut pas cliquer une phrase. L'URL est bâtie
sur l'adresse canonique du déploiement, jamais sur l'hôte de la requête entrante — sans quoi
l'appelant pourrait fabriquer le lien qu'on lui affiche.
⚠️ data porte le résultat de l'action, et son absence est significative. Avant, une
approbation exécutée rendait « Action exécutée. » et rien d'autre : un enrich_contact
facturé 10 crédits ne rendait pas ce qui venait d'être payé, et aucun outil de lecture ne
permettait de le relire. data est absent — pas vide — quand l'action n'a rien à rendre
(création de tâche, activation de séquence) : « rien à rendre » et « voici le résultat »
restent distinguables. Pour relire une fiche après coup, get_prospect est désormais là.
Outils de lecture (stats) — pour que l'agent suive ses résultats :
| Outil MCP | Endpoint | Renvoie |
|---|---|---|
get_campaign_stats | POST /api/copilot/stats/campaign | funnel + buckets d'une séquence (live), sur ses membres (enrôlés, ou rattachés par une interaction) ; un prospect supprimé du CRM n'est plus membre et ses interactions ne comptent plus |
list_campaigns | POST /api/copilot/stats/list | campagnes + status + work_state + compteurs (cache rollup) — découverte des sequence_id ; voir « Statut de commande et état de charge » plus bas |
get_org_stats | POST /api/copilot/stats/org | crédits restants + totaux (prospects, listes, campagnes, RDV) |
get_activity_report | POST /api/copilot/stats/report | activité par rep/séquence/liste (tâches, appels, RDV, issues, LinkedIn) — schéma complet dans openapi.yaml |
get_objections | POST /api/copilot/stats/objections | matrice objections catégorie × canal |
get_response_heatmap | POST /api/copilot/stats/heatmap | meilleurs créneaux de réponse par canal |
get_prospect | POST /api/copilot/prospects | une fiche prospect + toutes ses listes (relit un enrichissement payé). Prend prospect_id ou prospect_list_item_id ; la réponse porte resolved_by ("prospect_id" / "prospect_list_item_id") pour dire par quelle porte elle a répondu |
list_prospect_lists | POST /api/copilot/prospect-lists | listes de l'organisation (id, nom, date de création) — traduit un UUID en nom |
get_credit_requests | GET /api/credit-requests | vos demandes de crédits (10 dernières, statut, réponse) et votre autorisation restante ; une demande se fait par l'outil d'action request_credits (POST /api/credit-requests) |
get_list_duplicates | POST /api/copilot/list-duplicates | contacts d'une liste déjà dans une campagne de l'organisation (tous statuts) et les campagnes concernées. Prend list_id ou list_name ; un nom ambigu rend 409 doublons_liste_ambigue avec candidates, une liste absente 404 doublons_liste_introuvable ; aussi 400 doublons_parametre_invalide, 413 doublons_liste_trop_grande (plus de 20 000 contacts), 500 doublons_lecture_impossible |
Statut de commande et état de charge — deux axes, à ne pas confondre
list_campaigns rend trois champs sur chaque campagne, et ils répondent à des
questions différentes :
status— ce que l'opérateur a demandé :draft | active | paused | archived. Ce champ ne change jamais de sens.work_state— ce qu'il RESTE à faire au moteur :has_steps_left | no_steps_left | watching | empty | not_applicable | unknown.watch_health— si une veille alimente la campagne, DANS QUEL ÉTAT elle est :running | failing | paused | blocked | failed | stopped, ounull.
⚠️ watch_health est un champ FRÈRE, pas une valeur de plus dans work_state. watching
répond « une veille continue de l'alimenter » — et il le répondait à l'identique pour une veille
BLOQUÉE, EN ÉCHEC ou ARRÊTÉE, c'est-à-dire pour une campagne à laquelle plus rien n'arrivera.
Les deux champs répondent à deux questions : ce qu'il reste à faire, et si la source fonctionne
encore. Les mélanger aurait forcé chaque client à réapprendre une énumération qu'il tolère déjà.
⚠️ watch_health: null ne veut PAS dire « pas de veille ». Il vaut null pour une campagne
sans veille, pour une veille job-changes (son état vit dans une autre table), ET pour une
lecture ratée. La paire à lire est work_state: "watching" + watch_health: la première dit
qu'une veille alimente la campagne, la seconde ce qu'on sait de sa santé — null = rien.
⚠️ watching n'est PAS « terminée », et c'est le piège que ce champ a réellement posé.
Une campagne alimentée par un signal d'intent (job-changes, profile-viewers,
pending-invitations) atteint « tout le monde enrôlé a fini » entre deux relevés, puis la
veille lui amène de nouveaux prospects. Elle répondait donc no_steps_left à intervalle
régulier, et un agent le prenait au mot pour réaffecter ses prospects. Signalé en production
le 2026-09-21.
⚠️ no_steps_left n'affirme PAS un succès. Il veut dire « le moteur n'a plus d'étape
à exécuter », pas « tout a été envoyé ». Les statuts d'enrôlement terminaux incluent
bounced, failed et unreachable ; une campagne dont tous les envois ont échoué est
no_steps_left. Pour juger d'un résultat commercial, lire rdv_generes et reply_rate,
jamais work_state seul.
⚠️ unknown et not_applicable sont des refus explicites, pas des valeurs par
défaut : données incohérentes pour le premier, brouillon ou archive pour le second.
Tolérez une valeur inconnue — l'énumération peut s'étendre sans changement cassant.
⚠️ Les compteurs viennent d'un rollup rafraîchi toutes les 10 minutes. Ils sont
approximatifs et peuvent être en retard sur la réalité. get_campaign_stats rend des
chiffres vivants.
curl -sS https://app.prosperian.co/api/copilot/stats/list \
-H "X-API-Key: psk_live_…" \
-H "Content-Type: application/json" \
-d '{"status":"active","limit":50}'
{
"campaigns": [
{
"id": "6f1c2e84-0b2a-4f7e-9c31-2b7b5a0d9e11",
"name": "Relance Q3",
"status": "active",
"work_state": "no_steps_left",
"watch_health": null,
"prospects_total": 4188,
"prospects_termines": 4188,
"prospects_actifs": 0,
"taux_avancement": 100,
"rdv_generes": 12,
"reply_rate": 0.07,
"created_at": "2026-06-02T10:21:56Z"
}
],
"limit": 50
}
⚠️ Un status hors liste rend 400, pas un 200 vide. Avant la version 1.3,
{"status":"finished"} rendait 200 {"campaigns":[]} — indiscernable de « aucune
campagne ne correspond ». C'est un changement cassant pour un client qui envoyait une
valeur invalide :
{
"error": "Filtre de statut invalide",
"code": "invalid_status_filter",
"field": "status",
"received": "finished",
"allowed": ["draft", "active", "paused", "archived"]
}
Dépréciation —
get_activity_report. Chaque entrée deperUserporte désormaisdisplayName(string | null) etrelation(agency|member|left|unknown).userNamereste servi comme alias et vaut la chaîne vide quand l'auteur n'est pas nommable ; son retrait suppose un mécanisme de version que cette API n'a pas — voir Compatibilité.relationdit comment l'auteur se rattache au compte :member= membre actuel,left= son compte d'origine est celui-ci mais il opère ailleurs,agency= opérateur de l'agence qui gère le compte,unknown= non rattachable (les compteurs restent justes, le nom est tu). Ne jamais afficherdisplayNamenul tel quel.
Réponses e-mail —
get_activity_report.emailRepliescompte les réponses reçues par e-mail, dans les trois ventilations. ⚠️ Entrant, commelinkedinReplies: ne l'additionnez pas àemails, qui est le volume envoyé. Les règles d'exclusion sont celles de toutes les statistiques de réponse du produit — bounces toujours écartés, absences (OOO) selon le réglage d'organisation échoé dansfilters.countOoo. Servir « Réponses LinkedIn » sans son équivalent e-mail affirmait que l'e-mail ne reçoit pas de réponses ; ce n'était pas le cas.
Activité LinkedIn —
get_activity_report. Trois compteurs s'ajoutent aux lignes des trois ventilations :linkedinInvitationSteps(demandes de connexion envoyées),linkedinMessages(messages envoyés, réponses rédigées depuis l'inbox comprises) etlinkedinReplies(réponses reçues). ⚠️ Ce dernier est entrant : ne l'additionnez pas au volume sortant. Les bounces en sont toujours exclus ; les absences suivent le réglage d'organisation, désormais échoé dansfilters.countOoo— sans lui, deux appels identiques pouvaient rendre deux nombres différents sans que rien ne l'explique. Les visites de profil, likes, commentaires et recommandations ne sont pas comptés : le fournisseur écrit une interaction même quand le geste est un no-op.⚠️ Ces trois compteurs ne dépendent PAS de l'état de la collecte — ils sont lus dans la table des interactions, un
0y est un vrai0, et rien ne doit vous conduire à les masquer. ⚠️ Une réserve à part, qui ne vient pas de la couverture :linkedinInvitationStepscompte des étapes d'invitation effectuées, pas des invitations délivrées. Le fournisseur écrit une interaction même quand le prospect était déjà relation de 1er degré ou avait déjà une invitation en attente — c'est donc un majorant. Ce qui a besoin d'être qualifié, c'est l'autre paire :linkedin.invitesetlinkedin.connectes, qui se déduisent du cache de relations. Demandezinclude: ["linkedin"]et lisezlinkedin.couverture.etat:mesuree(collecte prouvée, ces deux nombres sont lisibles),non_connecte(aucun compte LinkedIn sain),inconnue(collecte non vérifiable — voircouverture.motif). Dans les deux derniers cas, affichez le libellé à la place de ces deux nombres-là, jamais0— et laissez les trois compteurs d'activité intacts.⚠️ Deux unités à ne pas confondre.
linkedin.invitescompte des personnes distinctes,linkedinInvitationStepsdes événements : mesuré 668 contre 942 sur une même organisation. Etlinkedin.connectesdit combien de ces personnes sont relations de 1er degré maintenant — une photo, pas un taux d'acceptation : aucune date d'acceptation n'existe en base, et l'on rapporte des invitations fenêtrées à un état présent. Ce ratio n'est donc pas comparable d'une période à l'autre, ni utilisable pour classer des personnes.
linkedin.exclutnomme ce qui n'entre pas dans ces compteurs — notamment les campagnes de commentaires et les veilles d'intent, qui n'écrivent aucune interaction.
⚠️ Rupture de valeurs —
linkedin.connecteschange de méthode (2026-09-11). Deux choses changent en même temps, et elles poussent le chiffre dans des sens opposés.1. Toutes les boîtes expéditrices comptent, plus une seule. Le calcul ne retenait qu'UNE boîte par personne invitée, choisie arbitrairement quand la personne avait été invitée depuis plusieurs comptes LinkedIn. Si l'invitation acceptée venait de l'autre compte, la personne ne comptait pas comme connectée. Elle compte désormais dès qu'un des comptes qui l'ont invitée la voit en 1er degré. Cet effet-là fait MONTER le chiffre.
2. Les comptes sont bornés au périmètre demandé. Seuls comptent désormais les comptes ayant réellement envoyé une invitation comptée dans le périmètre de votre requête (fenêtre, séquence, commercial, liste). Une personne qui n'était comptée connectée que via un compte n'ayant rien envoyé dans ce périmètre ne l'est plus. Cet effet-là fait BAISSER le chiffre.
La définition, elle, ne change pas : « parmi les personnes invitées, celles qui sont relations de 1er degré maintenant ». Les deux effets sont des corrections d'une attribution qui était arbitraire. Conséquence pratique : ne comparez pas une valeur relevée après le 2026-09-11 à une valeur relevée avant, dans aucune direction.
linkedin.invitesn'est pas touché.Amplitude mesurée au moment de la livraison : nulle. Sur les 1 018 personnes invitées connues, aucune n'était alors enrôlée depuis plus d'une boîte LinkedIn (effet 1), et aucune de celles dont les invitations avaient perdu leur séquence ne portait de boîte résolvable (effet 2). Le nouveau calcul rend donc exactement les mêmes valeurs que l'ancien. L'annonce est faite parce que l'écart apparaît dès qu'une organisation fait servir une même personne par deux comptes — pas parce qu'une série temporelle a déjà bougé.
Le garde-fou contre le sur-comptage tient là où l'expéditeur est connu : une relation de 1er degré d'un compte qui n'a pas contacté cette personne ne la compte pas comme connectée. Quand l'expéditeur n'est identifiable pour aucune de ses invitations — enrôlement sans boîte, boîte devenue introuvable, périmètre inconnu — le calcul retombe, comme avant, sur l'échelle de l'organisation, et là n'importe quelle relation de 1er degré compte. C'est le prix assumé d'une règle que ce calcul applique partout : une donnée manquante ne doit jamais faire disparaître quelqu'un du compteur.
⚠️ Rupture de valeurs — la ventilation par liste est désormais une PARTITION. Sur
get_activity_report(perList) comme surget_objections, un événement compte dans exactement une liste : celle à laquelle le prospect appartenait à la date de l'événement. Auparavant il était compté dans chacune des listes du prospect, donc un prospect présent dans trois listes pesait trois fois. Conséquence attendue : le total d'une liste nommée BAISSE — c'est la correction du double comptage, pas une perte de données — et une ligne « sans liste » apparaît là où il n'y en avait aucune.Quand le prospect était dans PLUSIEURS listes ce jour-là, la règle tranche : l'appartenance la plus récente parmi celles qui précèdent l'événement l'emporte, et à date d'ajout égale c'est le plus petit identifiant de liste. Sans ce départage la règle ne serait pas une partition, seulement une préférence.
get_objectionspublie le nombre de prospects concernés dansbyList_meta.shared_prospects(voir plus bas).⚠️ Une exception à la borne temporelle : quand la date de l'événement est absente ou illisible, il n'y a rien à quoi se figer et toutes les appartenances connues du prospect redeviennent éligibles, y compris postérieures à l'événement. Ces lignes-là peuvent donc changer après un import ultérieur — les autres non.
Deux propriétés à connaître avant de brancher un tableau de bord dessus :
- ⚠️
perListetperUserne se somment PAS au même nombre, et il ne faut pas l'attendre.perListcompte tous les événements ;perUserne compte que ceux dont l'auteur est résoluble. L'activité non attribuable existe donc dansperListseul. Un tableau de bord qui rapproche les deux totaux affichera un écart légitime.- Une entrée peut porter
listId: nulletlistName: null: c'est le seau « sans liste », qui recueille les événements d'un prospect n'appartenant à aucune liste à la date de l'événement. Sans lui, cette activité disparaîtrait de la ventilation et la somme par liste cesserait d'égaler le nombre d'événements. Il faut donc le rendre sous un libellé maison — jamais affichernulltel quel, jamais laisser tomber la ligne.
get_objectionsrend en plusbyList_meta(rule,sum,distinct_events,unlisted_events,shared_prospects) pour qu'un client puisse vérifier la partition sans relire le code :sumdoit égalerdistinct_events. C'est l'égalité qui manquait quand la ventilation annonçait 137 pour 174 événements réels.
- ⚠️
shared_prospectsne compte pas la même chose que ses voisins.sum,distinct_eventsetunlisted_eventscomptent des événements ;shared_prospectscompte des prospects — ceux qui appartenaient à plusieurs listes à la date de leur événement, donc ceux que la règle a dû arbitrer. Un prospect arbitré sur dix objections compte une fois. Le lire comme un nombre d'événements fait conclure à tort que la partition a peu déplacé.- Il vaut toujours
0quand la requête filtre déjà sur une liste : il n'y a alors rien à arbitrer.- Il n'explique pas à lui seul la baisse des totaux par liste. Un prospect dont toute appartenance est postérieure à l'événement n'est pas arbitré : il tombe dans le seau « sans liste », et c'est
unlisted_eventsqui le porte. Les deux se lisent ensemble.⚠️
perSequence.callspeut valoir 0 pour une raison qui n'est pas « aucun appel ». Quand c'est le cas, l'entrée portecalls_attribution: "unavailable_before_2026-09-01": les appels antérieurs au 2026-09-01 ne portent aucune séquence et aucun rattrapage n'est possible. Le champ est absent quand le compte est réel. Un zéro sans ce champ est un vrai zéro ; un zéro avec, c'est une donnée manquante — ne les affichez pas pareil.⚠️ Et dans l'autre sens, un rapport filtré par séquence passe de 0 à sa vraie valeur. L'appartenance d'un prospect à une séquence se lisait sur une colonne qui n'était renseignée que sur une poignée de lignes ; toute statistique filtrée par séquence rendait donc zéro. Elle se déduit maintenant des interactions et des enrôlements réels. Un chiffre qui passe de zéro à un nombre plausible est le même correctif vu par l'autre bout.
Les outils de lecture sont accessibles à toute clé API valide (modèle per-org, plein accès).
Installation avec une clé API (Claude Code, ou tout autre client MCP) :
claude mcp add --transport http prosperian \
https://app.prosperian.co/api/mcp \
-H "x-api-key: psk_live_xxx"
⚠️ Le CLI npm @prosperian/mcp n'a jamais été publié : l'endpoint hébergé ci-dessus
est la seule installation valide.
L'authentification accepte Authorization: Bearer comme x-api-key, avec la clé API per-org.
Connecteur claude.ai (OAuth 2.1)
claude.ai ne sait pas coller une clé API dans un header : pour ajouter un serveur MCP, il exige un serveur d'autorisation OAuth. Prosperian en sert un. On ajoute donc Prosperian comme connecteur personnalisé (claude.ai → Settings → Connectors → Add custom connector) avec la seule URL du serveur MCP :
https://app.prosperian.co/api/mcp
Le reste est automatique. Claude suit la chaîne 401 → métadonnées de ressource protégée (RFC 9728) → métadonnées du serveur d'autorisation (RFC 8414) → enregistrement dynamique du client (RFC 7591) → écran de consentement → jetons. Un seul maillon manquant produit
« Couldn't reach the MCP server », sans autre explication.
| Endpoint | Rôle |
|---|---|
GET /.well-known/oauth-protected-resource/api/mcp | RFC 9728 — décrit la ressource et pointe le serveur d'autorisation. La forme nue /.well-known/oauth-protected-resource est servie en alias |
GET /.well-known/oauth-authorization-server | RFC 8414 — métadonnées du serveur d'autorisation |
POST /api/oauth/register | RFC 7591 — enregistrement dynamique du client (non authentifié par nature, limité en débit) |
GET /oauth/authorize | écran de consentement, en session applicative |
POST /api/oauth/token | échange du code d'autorisation, puis rafraîchissement |
Ce qu'il faut savoir avant de brancher :
- Le consentement est réservé aux administrateurs de l'organisation. Un membre non administrateur voit l'écran refuser, avec le motif écrit.
- Le connecteur est lié à l'organisation affichée sur l'écran de consentement, pas à l'organisation « active » au moment du clic : basculer de client dans un autre onglet pendant que l'écran est ouvert ne détourne pas le jeton.
- PKCE
S256est obligatoire. Le client est public (aucun secret) et seuls lesredirect_urisurclaude.ai/claude.com, ou en boucle locale (http://127.0.0.1,http://localhost, port ignoré — RFC 8252 §7.3), sont enregistrables. Un hôte hors de cette liste est refusé en400nommant l'URI, jamais en silence. - Durées de vie : code d'autorisation 60 s, jeton d'accès 1 h, jeton de rafraîchissement 60 j. Le rafraîchissement tourne à chaque usage : un jeton rejoué est refusé, et un rejeu caractérisé révoque toute la chaîne — c'est la détection de vol de la BCP OAuth 2.1. En cas de doute sur un connecteur, révoquez-le depuis l'application.
- Le périmètre est celui d'une clé API. Le scope annoncé (
mcp) désigne l'accès au serveur MCP, pas un sous-ensemble d'outils : un connecteur peut tout ce que peut une clé API de l'organisation. Les outils d'écriture restent derrière le flux d'approbation décrit plus haut. - L'accès est revérifié à chaque appel, pas seulement au consentement : désactiver la personne, ou couper son lien d'agence, lui retire l'accès sans avoir à révoquer son jeton.
Révoquer un connecteur : Paramètres → API et développeurs, carte « Connecteurs MCP ». Elle liste qui a branché quoi, quand, et la date du dernier appel. Révoquer coupe le grant et ses jetons, immédiatement. Chacun peut révoquer le sien ; un administrateur peut révoquer n'importe lequel. Les clés API ne sont pas affectées.