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ées GET (par exemple GET /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ù) :

NotionNom dans l'API HTTPNom dans les outils MCP
Campagne / séquencesequence_id (parfois id)sequence_id
OrganisationorganizationId (corps)déduite de la clé
Succès d'une écrituresuccess: true (ou ok: true)même champ que la route

Sommaire


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.

CasRéponse
Header X-API-Key absent ou clé invalide / révoquée401 { "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, mais 404. Sur les trois routes de statistiques filtrables par séquence — get_campaign_stats, get_activity_report, get_objections — un sequence_id qui appartient à quelqu'un d'autre reçoit exactement la même réponse qu'un sequence_id qui 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 en X-API-Key. Voir Connecteur claude.ai (OAuth 2.1).

Obtenir une clé API

  1. Dans l'application : Paramètres → API et développeurs.
  2. 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).
  3. Pour révoquer une clé, supprimez-la depuis la même page. La révocation est immédiate.
  4. 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)

ChampTypeRequisDescription
criteria.descriptionstring✅Description en langage naturel des prospects recherchés
criteria.industrystring—Filtre secteur
criteria.companySizestring—Filtre taille d'entreprise
criteria.locationstring—Filtre localisation
criteria.revenuestring—Filtre chiffre d'affaires
criteria.technologiesstring—Filtre technologies
criteria.keywordsstring—Mots-clés additionnels
listNamestring—Nom de la liste créée (auto-généré si absent)
userContext.organizationIdstring—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.

ChampTypeRequisDescription
sourcestring✅"database"
max_creditsinteger✅Plafond DUR de crédits. Le volume est réduit au plus grand nombre de prospects dont le coût maximal tient dessous
idempotency_keystring✅ hors dry_run1 à 200 caractères. Couvre la création de la liste ET la facturation (voir plus bas)
dry_runboolean—true : estimation seule — rien n'est écrit ni débité, la clé n'est pas requise
countinteger—Prospects souhaités (défaut 10), réduit pour tenir sous max_credits
criteria.descriptionstring✅Lue par un modèle et traduite en filtres
criteria.industrystring—Secteurs séparés par des virgules. Prime sur la description
criteria.companySizestring—Tranches 11-50, 1000+, séparées par ;. Prime sur la description
criteria.locationstring—Lieux du siège (Lyon, France), séparés par ;. Prime sur la description
listNamestring—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 format min,max, min, = ouverte) — ce qui a VRAIMENT été cherché ;
  • rejected_constraints : [{ field, value, reason }], où reason vaut remplace_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), illisible ou trop_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) et extraction_unavailable. Une contrainte appliquée ainsi est retirée de rejected_constraints dans 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 rend 202 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 son demande_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é avec replay: 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.

StatutcodeSens
400source_inconnuesource différent de database
400description_requisecriteria.description absente
400max_credits_requismax_credits absent
400max_credits_invalidepas un entier > 0
400nombre_invalidecount pas un entier > 0
400cle_idempotence_requiseclé absente hors dry_run
400cle_idempotence_invalideclé vide ou de plus de 200 caractères
402credits_epuisessolde insuffisant ; rien n'est débité
403source_non_activeerecherche payante pas encore ouverte pour l'organisation
403approbation_credits_requisel'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)
404demande_introuvable(GET) aucune demande pour cette clé
409cle_idempotence_reutiliseeclé déjà utilisée pour une autre demande
422filtres_invalidesni axe personne, ni axe entreprise (missing_axes)
422max_credits_insuffisantle plus petit sourcing coûte plus que max_credits (minimum_credits)
422aucun_resultataucun 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
500etat_inconnudemande interrompue sans résultat enregistré
502traduction_echoueela description n'a pas pu être lue ; rien n'est débité, réessayez ou explicitez
503fournisseur_indisponiblela source de données n'a pas répondu ; la réservation est rendue
503droits_illisiblesvos 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)
503sourcing_indisponiblevotre 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)

ChampTypeRequisDescription
namestring✅Nom de la campagne
sequencearray—Étapes ordonnées (voir ci-dessous)
sequence[].typestring✅Type d'étape : mail_send, linkedin_message, logic_wait, …
sequence[].subjectstring—Objet (pour les étapes email)
sequence[].messagestring—Corps du message
sequence[].delaynumber—Délai en jours (pour les étapes d'attente)
descriptionstring—Description de la campagne
organization_idstring—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)

ChampTypeRequisDescription
sequence_idstring✅UUID de la séquence cible
prospect_list_idstring✅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.

⚠️ totalInSequence est déprécié — utilisez attachedInThisCall. Les deux portent la même valeur (le volume rattaché par CET appel), mais totalInSequence porte 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.

⚠️ outreach n'est présent que sur le chemin natif. Ce bloc était documenté ici avec un champ pushed que le code n'émet jamais — la forme réelle est { native, provider, enrolled }, où enrolled est 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)

ChampTypeRequisDescription
sequence_idstring✅UUID de la séquence
statusstring✅active ou paused
read_fingerprintstring—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 dans somme(ventilation) : l'invariant somme(ventilation) + exclus_pool + non_tentes = resolus − enroles est inchangé.
  • verrou — la politique appliquée, en lecture seule : mode, derogations, classement (calcule / non_calcule / indisponible), et quand classement vaut calcule, 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).

EndpointRôle
POST /api/enrichment/manualCréer + enrichir un nouveau contact (nom + société)
POST /api/enrichment/singleEnrichir un prospect existant (prospectId)
POST /api/enrichment/prospect-list-itemEnrichir une ligne de liste précise (itemId)
POST /api/enrichment/listEnrichir tous les prospects d'une liste existante
POST /api/enrichment/csvCréer une liste depuis des prospects fournis (≤ 1000) et l'enrichir
GET /api/enrichment/list-coverageCouverture et bilan d'enrichissement d'une liste (lecture seule)
POST /api/enrichment/sync-pending-itemRéconcilier une ligne en attente
POST /api/enrichment/sync-pending-listRéconcilier les lignes en attente d'une liste
POST /api/enrichment/pull-list-itemForcer le suivi de l'enrichissement d'une ligne

Enrichir un contact — POST /api/enrichment/manual

ChampTypeRequisDescription
first_namestring✅Prénom
last_namestring✅Nom
enrichmentTypesobject✅{ email?: boolean, phone?: boolean } — au moins un true
company_namestring—Société
company_domainstring—Domaine (ex. stripe.com) — améliore le taux de match
linkedin_urlstring—URL LinkedIn
itemIdstring—Reprise d'un contact déjà enrichi par /manual (voir plus bas)
organizationIdstring—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.candidate la donne, et le téléphone vaut not_searched / identity_mismatch tant qu'elle n'est pas confirmée ;

  • "not_found" : aucune URL ; le téléphone vaut alors not_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 aussi pending et la réponse porte pending: true. C'est l'autre requête qui conclut : poller sync-pending-item jusqu'à 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

ChampTypeRequisDescription
itemIdstring✅itemId rendu par /manual
decisionstring✅confirm ou reject
candidateUrlstring✅La candidate affichée (linkedin.candidate)
organizationIdstring—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

ChampTypeRequisDescription
prospectListIdstring✅Liste à enrichir
enrichmentTypesobject✅{ email?: boolean, phone?: boolean }
organizationIdstring—Optionnel avec une clé API
approval_action_idstring✅ avec une clé APIDemande validée par un humain : sans elle, la route refuse (403) sans rien débiter. Voir la validation des outils d'écriture plus bas
verifyEmailsboolean—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

  1. Lancer : POST /api/enrichment/list (liste existante) ou POST /api/enrichment/csv (nouvelle liste, son identifiant est dans listId).
  2. Réconcilier : POST /api/enrichment/sync-pending-list avec { "prospectListId": "…" }, toutes les 4 à 5 secondes, tant que pendingRemaining est supérieur à 0. Arrêtez-vous à pendingRemaining: 0 (la réponse porte alors summary) ou après une douzaine de minutes ; une réponse 500 (code: lecture_impossible) se réessaie telle quelle. Un appel travaille une quarantaine de secondes au plus : sur une longue file, pendingRemaining baisse d'appel en appel.
  3. Lire le bilan : GET /api/enrichment/list-coverage?prospectListId=… rend summary, sans aucun effet : par canal, found (valeur valide), pending (recherche en cours) et notFound (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 compteurs from… 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éponse 404 (code: liste_introuvable), sur list-coverage comme sur sync-pending-list (qui rendait 403 pour une autre organisation). Le message de POST /api/enrichment/list dit 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.

ChampTypeRequisDescription
listNamestring✅Nom de la liste créée
enrichmentTypesobject✅{ email?: boolean, phone?: boolean } — au moins un true
prospectsobject[]✅≤ 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_fieldsobject—Variables {{clé}} des séquences : clé ^[a-z_][a-z0-9_]*$ (≤ 64), valeur texte (coupée à 2000)
customVariableDefinitionsobject[]—{ key, label } : libellé affiché pour chaque clé (coupé à 128 caractères)
sourceColumnsstring[]—En-têtes (≤ 256 car.) des colonnes du fichier à conserver (≤ 200), dans l'ordre du fichier
prospects[].source_rowstring[]—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…k20 et une autre avec k11…k31 font 31 clés). Une clé au-delà, invalide ou réservée (first_name, ai_*…) n'est pas écrite ; elle est listée dans customFields.dropped de la réponse. Une clé dont aucune valeur n'est du texte (nombre, booléen, objet) y figure aussi, avec la raison invalid. dropped détaille au plus 100 clés ; droppedTotal donne 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 à null vaut absent.
  • Taille : le corps est limité à environ 4 Mo par la plateforme (réponse 413 en texte brut).
  • Le MCP import_prospects passe 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. Voir openapi.yaml pour 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" }
  • code est la valeur à lire pour décider quoi faire ; error est une phrase pour un humain, en français, qui peut changer.
  • Une erreur inattendue côté serveur rend 500 avec code: "erreur_interne" et une ref courte : donnez cette ref au 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) dans error ni details : 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 rend 502, code: "fournisseur_indisponible", sans son texte.
CodeSignification
400Requê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
401Clé API absente, invalide ou révoquée — ou clé dont le compte créateur a été désactivé (voir Authentification)
402Crédits d'enrichissement insuffisants ({ error, required, available })
403Clé non autorisée pour l'organisation ciblée
404Ressource 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
409Conflit. 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
500Erreur 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
502sourcing/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
503sourcing/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
503sourcing/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
502attach-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)
503attach-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
400POST /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
404import_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
409POST /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
503import_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 code qui discrimine, pas le statut HTTP — même convention que le 409 ci-dessus. Les quatre codes sont webhook_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) et erreur_serveur (500) ; ils sont stables. Le champ error reste servi pour les journaux, mais un client doit traduire le code : la phrase du serveur est en français, et l'afficher telle quelle est le « repli mort » décrit plus haut.

⚠️ Un 200 ne 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 un 200 — c'est motif_enrolement qui porte le pourquoi. Les 5xx sont 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êteValeur
Content-Typeapplication/json
User-AgentProsperian-Webhooks/1
Idempotency-Keyidentifiant stable de CETTE action dans CETTE exécution (identique à id du corps)
X-Prosperian-Timestamphorodatage de l'envoi, en secondes Unix
X-Prosperian-Signaturesha256=<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 prospect sont toujours présentes ; une valeur inconnue vaut null.
  • custom porte les « données supplémentaires » saisies dans l'action (null sinon). 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: true signale 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 (ou id). 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 :

  • source qui nomme un fournisseur → origine: "recherche" (les valeurs neutres restent : "database", mode de la recherche payante de find_leads, "input", "mixed"…) ;
  • provider qui nomme un fournisseur → mode_envoi: "natif" pour le moteur d'envoi, retiré sinon ;
  • compteurs de recherche nommés (enrich_list) → fromSearch (leur somme) ;
  • site d'une dépense qui nomme un fournisseur → alias neutre (enrichment/list, company-lists/enrich, sourcing/organization, calls/call), qu'il suffit de recopier dans request_credits : l'outil le retraduit ;
  • error / message (et le texte d'une erreur d'outil) qui nommerait un fournisseur → phrase neutre ; un code d'erreur dans ce cas → service_externe_indisponible ;
  • webhookResponse (réponse brute du moteur de sourcing de find_leads gratuit) 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.

AncienNouveauStatut de l'ancien
chemin historique de l'enrichissement de listePOST /api/enrichment/listServi à 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, rechercheToujours renvoyé, déprécié
provider (moteur d'envoi)mode_envoi : natifToujours 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, 503 au rattachement, 502 si 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: 30 sur « 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), ou aucun_resultat avec raison: "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 — rdv ne 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 ligne perUser) : RDV sans propriétaire, appels et tâches sans auteur, absent quand il serait nul ou quand le rapport est filtré par membre. La somme perUser[].rdv + nonAttribue.rdv égale désormais le total.
  • Colonnes de séquence (get_campaign_stats, list_campaigns et tout lecteur de rdv_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_linkedin exclut 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.base reste « membres depuis le lancement ») ; la carte appelsPasses compte des appels dédoublonnés, sans les messages vocaux ; le taux d'une carte (buckets[].cards[].rate) sans dénominateur vaut null, plus 0. Les taux des étapes (funnel.stages[].rate, largeur de barre) et les colonnes de séquence restent à 0 sans dénominateur : lire l'opérande avant d'interpréter un 0.
  • Leads (tableau de bord, Copilot monthly_goal / active_leads, colonne leads des séquences) — un Lead est un prospect ni RDV ni client ayant, sur la fenêtre, une réponse positive ÉCRITE (statut qualifie) ou un appel qualifié « à relancer » (statut autre que non_qualifie). Le drapeau lead seul ne suffit plus. monthly_goal.current porte 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 rendre paiement: "offert" (session réglée à 0 € par un code promo) et raison: "coupon_non_autorise" (coupon non restreint au produit de recharge : jamais crédité).
  • POST /api/stripe/checkout-credits-topup peut rendre 503 configuration_produit quand STRIPE_PRODUCT_ID_RECHARGE est 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-subscription change de SENS sans changer de forme. Sans immediat, la nouvelle quantité n'est plus écrite sur l'abonnement : elle est PROGRAMMÉE pour le prochain cycle. La réponse porte quantity = quantité EFFECTIVE (inchangée) et pendingQuantity = quantité programmée. Avec immediat: true, la hausse est facturée au prorata tout de suite.
  • GET /api/stripe/credits-subscription gagne pendingQuantity et upgradeImmediateOpen.

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, /portal et POST /api/billing/unsubscribe rendent 403 avec code: "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 customerId envoyé à /api/stripe/portal, /api/stripe/checkout ou /api/stripe/checkout-subscription est 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-mailboxes et /api/stripe/checkout-domains rendent 403 admin_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. successUrl et cancelUrl sont ignorées si elles pointent vers un autre site ; le payeur revient sur la page par défaut.
  • POST /api/sourcing/prospects exige une organisation. Une session sans userContext.organizationId rend 400, 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 mapping qui 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 :

SituationAvantDepuis
Liste d'une autre organisation dans prospectListId403 « Liste hors de votre organisation »404 liste_introuvable
prospectListId archivée ou désactivéeimport accepté409 liste_indisponible
Rejeu d'un runId vers un AUTRE prospectListIdle run était repointé, ses lignes coupées en deux409 run_liste_differente
Rejeu d'un runId dont la liste a été supprimée ou archivéeune nouvelle liste était créée409 run_liste_indisponible (l'outil MCP relance seul, jusqu'à 3 fois)
Rejeu d'un runId avec un autre listNamele nom était reluignoré : le run est lié à sa liste
Panne à la création de la liste ou du run500503 preparation_import_indisponible, réessayable
Liste d'entreprises impossible à créerimport SANS entreprise, 200503, 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 MCPEndpoint
find_leadsPOST /api/sourcing/prospects
create_campaignPOST /api/copilot/create-campaign
attach_prospectsPOST /api/sequences/attach-prospects
start_sequencePOST /api/sequences/update-status
enrich_contactPOST /api/enrichment/manual
enrich_listPOST /api/enrichment/list
import_prospectsPOST /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 runId est 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, listName est ignoré. Le rejouer vers une autre liste rend 409 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_indisponible et 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.

statusChampsCe qu'il faut en faire
pending_approvalapproval_action_id, approval_url, messagerien n'a été exécuté ni facturé ; ouvrir approval_url, faire valider, puis rappeler l'outil
executedmessage, data (optionnel)l'action a eu lieu ; data porte son résultat
errormessagerien 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 MCPEndpointRenvoie
get_campaign_statsPOST /api/copilot/stats/campaignfunnel + 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_campaignsPOST /api/copilot/stats/listcampagnes + status + work_state + compteurs (cache rollup) — découverte des sequence_id ; voir « Statut de commande et état de charge » plus bas
get_org_statsPOST /api/copilot/stats/orgcrédits restants + totaux (prospects, listes, campagnes, RDV)
get_activity_reportPOST /api/copilot/stats/reportactivité par rep/séquence/liste (tâches, appels, RDV, issues, LinkedIn) — schéma complet dans openapi.yaml
get_objectionsPOST /api/copilot/stats/objectionsmatrice objections catégorie × canal
get_response_heatmapPOST /api/copilot/stats/heatmapmeilleurs créneaux de réponse par canal
get_prospectPOST /api/copilot/prospectsune 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_listsPOST /api/copilot/prospect-listslistes de l'organisation (id, nom, date de création) — traduit un UUID en nom
get_credit_requestsGET /api/credit-requestsvos 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_duplicatesPOST /api/copilot/list-duplicatescontacts 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, ou null.

⚠️ 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 de perUser porte désormais displayName (string | null) et relation (agency | member | left | unknown). userName reste 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é. relation dit 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 afficher displayName nul tel quel.

Réponses e-mail — get_activity_report. emailReplies compte les réponses reçues par e-mail, dans les trois ventilations. ⚠️ Entrant, comme linkedinReplies : 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é dans filters.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) et linkedinReplies (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é dans filters.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 0 y est un vrai 0, et rien ne doit vous conduire à les masquer. ⚠️ Une réserve à part, qui ne vient pas de la couverture : linkedinInvitationSteps compte 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.invites et linkedin.connectes, qui se déduisent du cache de relations. Demandez include: ["linkedin"] et lisez linkedin.couverture.etat : mesuree (collecte prouvée, ces deux nombres sont lisibles), non_connecte (aucun compte LinkedIn sain), inconnue (collecte non vérifiable — voir couverture.motif). Dans les deux derniers cas, affichez le libellé à la place de ces deux nombres-là, jamais 0 — et laissez les trois compteurs d'activité intacts.

⚠️ Deux unités à ne pas confondre. linkedin.invites compte des personnes distinctes, linkedinInvitationSteps des événements : mesuré 668 contre 942 sur une même organisation. Et linkedin.connectes dit 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.exclut nomme 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.connectes change 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.invites n'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 sur get_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_objections publie le nombre de prospects concernés dans byList_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 :

  • ⚠️ perList et perUser ne se somment PAS au même nombre, et il ne faut pas l'attendre. perList compte tous les événements ; perUser ne compte que ceux dont l'auteur est résoluble. L'activité non attribuable existe donc dans perList seul. Un tableau de bord qui rapproche les deux totaux affichera un écart légitime.
  • Une entrée peut porter listId: null et listName: 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 afficher null tel quel, jamais laisser tomber la ligne.

get_objections rend en plus byList_meta (rule, sum, distinct_events, unlisted_events, shared_prospects) pour qu'un client puisse vérifier la partition sans relire le code : sum doit égaler distinct_events. C'est l'égalité qui manquait quand la ventilation annonçait 137 pour 174 événements réels.

  • ⚠️ shared_prospects ne compte pas la même chose que ses voisins. sum, distinct_events et unlisted_events comptent des événements ; shared_prospects compte 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 0 quand 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_events qui le porte. Les deux se lisent ensemble.

⚠️ perSequence.calls peut valoir 0 pour une raison qui n'est pas « aucun appel ». Quand c'est le cas, l'entrée porte calls_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.

EndpointRôle
GET /.well-known/oauth-protected-resource/api/mcpRFC 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-serverRFC 8414 — métadonnées du serveur d'autorisation
POST /api/oauth/registerRFC 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 S256 est obligatoire. Le client est public (aucun secret) et seuls les redirect_uri sur claude.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é en 400 nommant 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.

API HTTP — Documentation Prosperian