CompanyData — Contrat d'API← InterfaceContrat d'APIRoadmapRequêtesSwagger

CompanyData — API

Service auto-hébergé d'enrichissement : vérification d'e-mail, qualification d'entreprise (par domaine ou SIREN), validation de téléphone, Email Finder, logo/avatar, et nettoyage de référentiel assisté par IA (Clean Review). Conçu pour fiabiliser vos envois, nettoyer vos listes et enrichir vos contacts — sans dépendance à un fournisseur SaaS tiers.


1. À quoi sert cette API — et pourquoi

Avant d'écrire à un contact ou de l'importer dans un CRM, on a besoin de savoir deux choses : l'adresse est-elle valide ? et quelle est l'entreprise derrière le domaine ? Les services du marché (Abstract, Clearbit, ZeroBounce…) répondent, mais :

CompanyData remplit le même rôle en interne, avec trois partis pris :

  1. Auto-hébergé, sans fournisseur tiers (la seule dépendance optionnelle est un LLM pour l'extraction difficile). Vos données ne sortent pas.
  2. Verdicts honnêtes et explicités : chaque réponse porte une raison et les signaux qui la fondent. On ne prétend jamais « délivrable » sans preuve.
  3. Zéro atteinte à la réputation d'envoi : le service n'envoie jamais de mail pour tester, et évite les sondes risquées (voir §4).

Ce qu'elle fait, concrètement

Service Question à laquelle il répond Endpoint
Vérification d'e-mail Cette adresse est-elle livrable ? risquée ? à jeter ? GET /validate/email
Qualification d'entreprise Qui est l'entreprise derrière ce domaine ? GET /enrich
Image Quel avatar/logo associer à ce contact/domaine ? GET /image
Validation de téléphone Ce numéro est-il valide ? mobile/fixe ? quel opérateur/pays ? GET /validate/phone
Email Finder Quel est l'e-mail pro probable de cette personne ? GET /find/email
Enrichissement par SIREN Quelle entreprise (+ domaine, logo) derrière ce SIREN ? GET /enrich/siren
Clean Review Détecter & corriger (IA) les incohérences du référentiel POST /review/arbitrate

Cas d'usage typiques : nettoyage de listes avant campagne, enrichissement CRM, validation de formulaire d'inscription, affichage d'un logo de société, normalisation de numéros de téléphone, résolution SIREN ↔ domaine.


2. Comment ça marche

2.1 Vérification d'e-mail — un pipeline en couches

L'adresse traverse 4 couches, de la moins chère à la plus coûteuse ; on s'arrête dès qu'une couche tranche :

  1. Syntaxe & normalisation — validité RFC, minuscule, domaine en IDNA/punycode. Invalide → invalid.
  2. MX (DNS) — le domaine a-t-il des serveurs de messagerie ? Aucun MX → undeliverable. On identifie au passage l'opérateur (mx_provider).
  3. Classification — domaine jetable (liste embarquée), adresse générique/rôle (contact@, info@…), messagerie grand public.
  4. Sonde SMTP (optionnelle, smtp=true) — dialogue RCPT TO sans envoyer de mail, avec détection catch-all. Confirme (deliverable) ou infirme (undeliverable) la boîte.

Le résultat est un verdict scoré : status + score (0–100) + reason + signaux.

Identification de l'opérateur MX (mx_provider). On sait distinguer : - les messageries (Google, Microsoft 365, Outlook grand public, Yahoo, iCloud, Proton, Zoho, OVH, Gandi, Infomaniak, IONOS…) ; - les passerelles anti-spam placées devant la vraie boîte (Mailinblack, Altospam, Vade, MailCleaner, Proofpoint, Mimecast, Barracuda, Cisco IronPort, Trend Micro, Symantec/MessageLabs, Hornetsecurity, SpamExperts, Libraesva, Retarus, SpamTitan…) ; - les FAI français (Orange, Free, SFR, La Poste, Bouygues).

Cette distinction est décisive pour la fiabilité : voir §4.

Cache. Chaque verdict est mémorisé avec une date de péremption (expires_at) : ~30 jours pour un verdict stable (délivrable / sans MX / invalide), ~7 jours pour un verdict incertain. Un nouvel appel resert le cache tant qu'il est frais ; refresh=true force un recalcul.

2.2 Qualification d'entreprise — heuristique + registre + LLM

À partir d'un domaine (ou d'une URL/e-mail dont on extrait le domaine) :

  1. Lecture du site — page d'accueil + pages légales (mentions légales), avec repli HTTP et détection des pages « par défaut » (listing Apache, parking…).
  2. Extraction heuristique (hors ligne, déterministe) — JSON-LD, nom & baseline, adresse française décomposée (ville / code postal / pays), SIREN (validé par Luhn), e-mails, téléphones, personnes + rôles, réseaux sociaux (Twitter/X fusionnés), détection « à vendre ».
  3. Consolidation registre — via recherche-entreprises.api.gouv.fr (DINUM), par SIREN ou par nom uniquement en présence d'un signal français (évite les faux positifs sur des homonymes étrangers). Quand le SIREN n'a pas été trouvé sur le site (recherche par nom, plus incertaine), le SIREN et la raison sociale ne sont associés à la fiche que si l'adresse du registre est compatible avec celle du site — même ville et même rue (numéro exclu), ou l'une incluse dans l'autre. En cas d'incompatibilité (homonyme probable), le registre est écarté et la source registry_rejected_address est tracée dans le run. La provenance du SIREN/raison sociale (page du site vs fiche Annuaire des Entreprises) est conservée dans l'historique.
  4. Repli LLM (optionnel, llm=true) — meilleure lecture des pages difficiles, et repli « connaissance » quand le site est bloqué/injoignable ou trop maigre : le modèle identifie l'entreprise à partir du domaine (donnée tracée, non inventée).
  5. Repli dichotomique de domaine — un sous-domaine stérile remonte vers le domaine parent (alias_of).

Normalisation d'adresse (BAN) : l'adresse scrapée est validée via la Base Adresse Nationale (§3.10). Sur score fort (≥ 0.8, adresse précise), on adopte la ville / code postal / citycode INSEE de la BAN (la voie scrapée est conservée avec ses accents) et address_status="resolved". Sinon address_status="ambiguous" → une suggestion clean-review « adresse à confirmer » est créée (on ne modifie rien tant qu'elle n'est pas validée).

Fusion non destructive : un rafraîchissement ne remplace jamais une donnée existante par du vide ; les listes (contacts, réseaux…) sont unifiées. Péremption ~9 mois (infos stables), raccourcie si données pauvres, DNS récemment modifié, ou domaine à vendre. enrichment_level indique si un LLM est intervenu (basic vs llm).

Pour une query (domaine ou e-mail), on cherche dans l'ordre, on s'arrête au premier hit : Gravatar (photo de personne, sur l'e-mail) → logo.dev (logo d'entreprise, sur le domaine) → BIMI (logo de marque publié en DNS) → favicon du site. Les octets sont téléchargés et mis en cache (la clé logo.dev n'est jamais exposée), avec péremption par source (Gravatar 2 mois, logo.dev/BIMI 3 mois, favicon 1 mois).


3. Référence des endpoints

Codes d'erreur transverses :

Code Signification
401 clé API absente ou incorrecte
400 requête invalide (domaine mal formé, batch > 50…)
422 e-mail grand public fourni là où un domaine d'entreprise est attendu

3.1 GET /validate/email

Param Type Défaut Description
email string requis adresse à vérifier
refresh bool false ignorer le cache
smtp bool false activer la sonde SMTP (RCPT TO, sans envoi)
curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/validate/email?email=jean@exemple.fr&smtp=true"
{
  "email": "jean@exemple.fr",
  "domain": "exemple.fr",
  "status": "deliverable",
  "score": 95,
  "reason": "boite confirmee par le serveur SMTP",
  "syntax_ok": true,
  "has_mx": true,
  "mx_hosts": ["mx1.exemple.fr"],
  "mx_provider": "ovh",
  "is_free": false,
  "is_disposable": false,
  "is_role": false,
  "is_catch_all": false,
  "smtp_checked": true,
  "smtp_code": "250",
  "expires_at": "2026-08-14T20:00:00Z",
  "check_count": 1,
  "from_database": false,
  "image_url": "https://companydata.scale2sell.company/image/raw?query=jean%40exemple.fr",
  "image_source": "gravatar",
  "image_type": "person"
}

status et recommandation pour du nettoyage de liste :

status Sens Action liste
deliverable boîte confirmée (SMTP) ✅ garder
undeliverable pas de MX, ou boîte rejetée (550) ❌ retirer
invalid syntaxe invalide ❌ retirer
risky jetable, ou serveur catch-all ⚠️ à revoir
untestable MX présent, boîte non confirmée (opérateur non sondable) ✅ garder par défaut

Champs clés : score (0–100), mx_provider (voir §2.1), is_catch_all (true/false/null), smtp_checked (la sonde a-t-elle réellement eu lieu), is_disposable/is_role/is_free, image_* (voir §3.4).

3.2 POST /validate/batch

// requête (≤ 50)          // réponse
{ "emails": ["a@x.fr"],    { "results": [ <objet ci-dessus>, … ] }
  "smtp": false }

3.3 GET /enrich

Param Type Défaut Description
domain string requis domaine, URL ou e-mail
refresh bool false ignorer le cache
llm bool false activer l'extraction LLM
curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/enrich?domain=scale2sell.company"
{
  "domain": "scale2sell.company",
  "company_name": "Scale2Sell",
  "baseline": "votre entreprise vaut plus que ce que vous croyez",
  "legal_name": "SCALE2SELL SAS",
  "siren": "912345678",
  "address": "12 rue de la République 13001 Marseille",
  "city": "Marseille", "postal_code": "13001", "country": "France",
  "citycode": "13201", "address_status": "resolved", "address_score": 0.97,
  "phone": "04 91 00 00 00", "phones": ["04 91 00 00 00"],
  "emails": ["contact@scale2sell.company"],
  "contacts": [{"name": "Jean Dupont", "role": "Président"}],
  "description": "…",
  "socials": {"linkedin": "https://www.linkedin.com/company/scale2sell"},
  "linkedin_people": ["https://www.linkedin.com/in/jean-dupont"],
  "linkedin_companies": [],
  "registry": {"siren": "912345678", "legal_name": "SCALE2SELL SAS", "naf": "70.22Z",
               "creation_date": "2018-03-01", "employees_range": "6 à 9 salariés"},
  "finances": {"exercice": "2024-12-31", "ca": 302946, "resultat_net": 28299,
               "ca_evolution_pct": 11.5, "tendance": "hausse",
               "exercices": [{"annee": 2024, "ca": 302946, "resultat_net": 28299}]},
  "sources": ["homepage", "legal_pages", "recherche_entreprises"],
  "status": "enriched",
  "enrichment_level": "basic",
  "alias_of": null,
  "expires_at": "2027-04-11T00:00:00Z",
  "enrichment_count": 1,
  "from_database": false,
  "image_url": "https://companydata.scale2sell.company/image/raw?query=scale2sell.company",
  "image_source": "logodev",
  "image_type": "company"
}

Finances (finances, best-effort, clé par SIREN — source : ratios INPI/BCE via data.economie.gouv.fr) : dernier exercice public connu (ca, resultat_net, exercice), historique des 3 derniers exercices (exercices) et tendance de CA (tendancehausse|baisse|stable, ca_evolution_pct). null si l'entreprise ne dépose pas de comptes (cas des entrepreneurs individuels / professions libérales). La date de création est dans registry.creation_date. En cas d'échec transitoire (timeout de la source), un réessai différé est programmé (~1 h) ; s'il aboutit, la fiche est mise à jour et un événement company.updated est poussé aux webhooks (§3.8) — inutile de re-scraper.

Analyse technique (dans la réponse enrich) : - tech_stack : [{name, category, evidence}] — CMS, framework, analytics, CDN, serveur… (détection statique HTML + en-têtes, sans rendu JS). - hosting : {domain, name, asn, cdn} — hébergeur/CDN (DNS + ASN). Si le site est derrière un CDN, domain/name reflètent le CDN (origine masquée). - agency : {domain, name, url} — agence créditée dans le pied de page / mentions légales (seulement si crédit explicite, sinon null).

Hébergeurs et agences sont aussi des entités référençables par leur domaine : - GET /hosts · GET /hosts/{domaine} → l'hébergeur + les sites qu'il héberge. - GET /agencies · GET /agencies/{domaine} → l'agence + les sites qu'elle a réalisés. - Filtres croisés : GET /companies?hosting=<domaine> et ?agency=<domaine>.

Sémantique : statusenriched | partial | unreachable ; enrichment_levelbasic | llm ; socials = dict plateforme→url (linkedin, facebook, instagram, x, youtube, tiktok) ; contacts = [{name, role|null}] ; registry = données registre FR ou null ; alias_of = domaine parent si alias ; query = echo de l'input si e-mail/URL.

LinkedInsocials.linkedin est le profil officiel de l'entreprise : la page entreprise (/company/, /school/, /showcase/) la plus fréquente sur le site ; à défaut (cabinet individuel sans page entreprise) le profil personnel (/in/, /pub/) le plus fréquent. Deux champs complètent (rétrocompatibles, tableaux, vides par défaut) : linkedin_people = profils personnels des associés/collaborateurs ; linkedin_companies = pages entreprise supplémentaires (renseigné seulement si le site en cite plusieurs). Les URLs sont canonicalisées (https://www.linkedin.com/<type>/<slug>, sans locale ni paramètres).

Entrées : une URL ou un e-mail sont acceptés (domaine extrait). E-mail grand public → 422. Domaine mal formé → 400. POST /enrich/batch : { "domains": [...] } (≤ 50).

3.4 Image

Les réponses enrich/validate incluent (best-effort) :

Champ Description
image_url URL absolue à mettre dans <img src> (ou null)
image_source gravatar | logodev | bimi | favicon
image_type person (avatar) | company (logo)

3.5 Déclaration de bounces (adresse périmée)

Un bounce (rebond) est un signal négatif fiable : votre système d'envoi le reçoit quand un message n'a pas pu être délivré. En le déclarant ici, l'adresse est marquée périmée — verdict autoritaire qui prime sur toute revalidation future (même une adresse Gmail « untestable » (ex-« unknown ») passe à undeliverable).

curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" -X POST \
  https://companydata.scale2sell.company/bounces \
  -d '{"email":"jean@exemple.fr","type":"hard","code":"550 5.1.1","source":"mailgun"}'

POST /bounces → renvoie la validation mise à jour (bounced: true, status: "undeliverable", bounce_count, bounce_reason). POST /bounces/batch{ "bounces": [ {email,type,…}, … ] } (≤ 500). DELETE /bounces/{email} → annule le marquage (correction), force une revalidation.

Filtrer les adresses périmées : GET /validations?bounced=true.

Intégration type : brancher le webhook de bounce de votre outil d'envoi (ou un traitement des NDR de votre boîte) sur POST /bounces. Les campagnes suivantes consultent status/bounced et suppriment les périmées.

3.6 Déclaration de délivrabilité (engagement — miroir positif du bounce)

Symétrique du bounce : un engagement (ouverture ou clic) prouve que la boîte a accepté le message → l'adresse est marquée délivrable, verdict autoritaire positif qui prime sur une revalidation future (et se propage à tous les abonnés).

On notifie l'e-mail, l'heure d'envoi, le type d'événement (open/click) et l'heure de l'événement :

curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" -X POST \
  https://companydata.scale2sell.company/deliverables \
  -d '{"email":"jean@corp.com","event":"click",
       "sent_at":"2026-07-16T09:00:00Z","event_at":"2026-07-16T11:30:00Z","source":"sendgrid"}'

Heuristique anti-spam : on calcule le délai event_at − sent_at. Un antispam ouvre/clique en quelques secondes ; plus l'événement est éloigné de l'envoi, plus l'engagement est probablement humain. Au-delà de engagement_human_delay_s (défaut 120 s) → confiance maximale. La délivrabilité est confirmée dans tous les cas (le mail a bien été délivré), mais le score est modulé :

Événement délai ≥ seuil (humain) délai < seuil (possible bot)
clic 99 90
ouverture 96 85

Résurrection : déclarer délivrable une adresse actuellement bounced la fait « ressusciter » (bounced→false, resurrection_count += 1). Maximum 3 résurrections ; au-delà, l'adresse est définitivement bounced et la déclaration est refusée (réponse {"refused": true, …}, status reste undeliverable).

Intégration type : brancher les webhooks open/click de votre outil d'emailing (SendGrid, Mailgun, Brevo…) sur POST /deliverables.

3.7 Édition manuelle (corriger une valeur scrapée)

Les données extraites automatiquement peuvent être corrigées à la main. Tout champ édité devient un « override » : il n'est plus jamais écrasé par un ré-enrichissement, et la modification est propagée aux webhooks (§3.8).

curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" -X PATCH \
  https://companydata.scale2sell.company/companies/exemple.fr \
  -d '{"company_name":"Nom Corrigé","city":"Lyon"}'

Bloc registre (par SIREN). Deux gestes symétriques, hors du chemin de scraping :

Dans les deux cas ces trois champs sont verrouillés en override : un ré-enrichissement ne défait pas la décision. Les deux émettent company.updated.

curl -H "X-API-Key: $KEY" -X POST \
  "https://companydata.scale2sell.company/companies/exemple.fr/registry/refresh?siren=831823257"

3.8 Webhooks (propagation des modifications)

Vos applications s'abonnent ; à chaque modification d'une entreprise ou d'une adresse, CompanyData pousse la donnée fraîche en POST signé. Ainsi, si vous corrigez un nom ici, il se met à jour chez tous les abonnés ; si un e-mail bascule en bounce, l'info est propagée à tous les clients.

Gérer les abonnements : - POST /webhooks{ "url": "...", "events": ["*"], "secret"?: "...", "description"?: "..." }. Le secret est généré s'il est absent (à conserver : il signe les payloads). Renvoie l'abonnement. - GET /webhooks · PATCH /webhooks/{id} ({url?, events?, active?, description?}) · DELETE /webhooks/{id}. - POST /webhooks/{id}/test — envoie un événement ping de test.

Événements : company.updated, email.updated, address.updated (ou ["*"] pour tout ; "company.*" supporté).

Requête reçue par votre endpoint :

POST /votre-webhook HTTP/1.1
Content-Type: application/json
X-CompanyData-Event: company.updated
X-CompanyData-Signature: sha256=3f9a…   ← HMAC-SHA256(secret, corps brut)

{
  "event": "company.updated",
  "at": "2026-07-15T20:00:00Z",
  "data": { "domain": "exemple.fr", "company_name": "Nom Corrigé", "city": "Lyon", ... }
}

Vérifier la signature (à faire systématiquement, sur le corps brut avant tout parsing) :

import hmac, hashlib

def verify(secret: str, raw_body: bytes, header_sig: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig or "")
const crypto = require('crypto');
function verify(secret, rawBody, sig) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return sig && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}

Receveur minimal (FastAPI) :

@app.post("/webhook")
async def receive(request: Request):
    raw = await request.body()
    if not verify(SECRET, raw, request.headers.get("X-CompanyData-Signature", "")):
        raise HTTPException(401, "signature invalide")
    p = json.loads(raw)
    if p["event"] == "company.updated":
        upsert_company_by_domain(p["data"])   # met à jour votre base adhérents
    elif p["event"] == "email.updated":
        upsert_email(p["data"])               # p["data"]["bounced"], ["status"]…
    elif p["event"] == "address.updated":
        upsert_address_by_key(p["data"])      # join sur p["data"]["key"] (ID surrogate)
    return {"ok": True}                        # répondre 2xx rapidement

Sémantique de livraison : - Best-effort, concurrente, timeout ~5 s par abonné ; une modification n'échoue jamais à cause d'un abonné KO (le compteur failure_count est exposé dans GET /webhooks). - Pas de file de retry automatique pour l'instant : répondez vite en 2xx. Un même événement peut être re-livré → traitez de façon idempotente (clé = domain/email/key, en gardant le plus récent via at).

3.9 Import CSV — mode « scan » (détection dédupliquée en masse)

Deux modes d'import coexistent : le scan (ci-dessous) détecte et déduplique les e-mails/ domaines de tout le fichier ; l'import enrichi ligne à ligne (§3.11) mappe les colonnes et produit une sortie enrichie par ligne, exportable. Choisissez le scan pour dédoublonner un gros volume, l'enrichi pour repartir du fichier complet.

Envoyez un fichier CSV : le service détecte toutes les adresses e-mail et tous les domaines présents (n'importe quelle colonne), en excluant les liens vers les réseaux sociaux (linkedin.com, facebook.com, x.com…), les messageries grand public (gmail.com…) et les domaines jetables. Les e-mails détectés sont validés (délivrabilité) et les domaines (y compris celui de chaque e-mail professionnel) enrichis. Le traitement est asynchrone : la création renvoie immédiatement, le travail se fait en tâche de fond, et l'IHM (onglet « Imports CSV ») comme l'API permettent de suivre l'avancement.

3.10 Normalisation d'adresse (Base Adresse Nationale)

Service réutilisable de parsing + normalisation d'adresse, adossé à la BAN (API Adresse, data.geopf.fr, gratuite et faisant autorité en France). Renvoie des composants structurés, un score de confiance et une validation ville/CP. Repli sur un parseur FR local si la BAN ne répond pas. Aucune exclusion : c'est un moteur générique (une adresse d'hébergeur ou de médiateur est une adresse valide).

curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/normalise/address?q=19+rue+de+la+republique+BP+418+13177+Marseille+Cedex+20"
{
  "key": "b3f1c2a4d5e6478091a2b3c4d5e6f708",
  "raw": "19 rue de la republique BP 418 13177 Marseille Cedex 20",
  "raw_key": "19 rue de la republique bp 418 13177 marseille cedex 20",
  "complement": "BP 418 · Cedex 20",
  "status": "resolved",
  "source": "ban",
  "housenumber": "19", "street": "Rue de la République",
  "postcode": "13002", "citycode": "13202", "city": "Marseille", "country": "France",
  "score": 0.97, "ban_type": "housenumber",
  "lat": 43.30, "lon": 5.37,
  "city_matches_postal": true,
  "alias_of": null,
  "request_count": 1, "last_request_at": "2026-07-22T09:00:00Z",
  "from_cache": false
}

Gating par confiance (status) : resolved si score ≥ 0.8 et ban_type=housenumber (adresse précise) ; sinon ambiguous (à valider — ex. « rue de la paix » sans numéro sort à score élevé mais type=street) ; parsed_local si repli parseur FR ; not_found sinon.

ID surrogate opaque (key) — ce que le consommateur stocke et joint, sans jamais le recalculer. C'est un UUID opaque (32 hex), sans lien dérivable avec le contenu, attribué une seule fois à la création de la fiche et immuable : une correction, une résolution BAN tardive ou une levée de doute ne le change jamais. Ainsi une mise à jour (address.updated, §3.8) reste toujours raccordable côté consommateur. Deux bruts qui résolvent à la même identité (même id BAN, ou mêmes composants) partagent le même key — les variantes « … CS 80336 » / sans CS, « … Cedex » / sans Cedex dédupliquent et le doublon est aussi lié en alias_of. (raw_key = forme canonique du brut, interne au cache ; ne pas joindre dessus.)

Compléments d'adresse (complement) — les mentions hors géocodage (2ème étage, Bât C, Apt 4, Escalier B, Porte 3, RDC, et les codes de distribution BP / CS / TSA / CEDEX) sont séparées dans complement et retirées avant l'appel BAN (elles bruitent le score). Elles ne sont jamais jetées (critiques pour la livraison postale) : conservez street et complement pour une adresse d'envoi complète.

Levée de doute en cascade — si le géocodage du brut ne tranche pas franchement, le service nettoie l'adresse (compléments retirés) et re-géocode : - deux formulations → même id BAN ⇒ doute levé → resolved (corroboration) ; - deux formulations → id différentsambiguous (revue) ; - le brut ne donnait rien de fiable, le nettoyé résout franchement ⇒ adopté (resolved). Sur resolved, la BAN renvoie le vrai CP/citycode (ex. le Cedex 13177 devient 13002 / 13202).

Complétion au niveau rue — quand la BAN trouve la voie mais pas le numéro (réponse type=street) et que la saisie contenait un numéro, le service pose le numéro détecté sur la voie officielle BAN (nom complet + CP/citycode/coordonnées) → resolved. Il ne le fait que si la voie BAN est compatible avec la voie saisie (« Avenue Saint Just » ↔ « Avenue Louis Antoine Saint Just » : oui ; « Rue Jean Moulin » ↔ « Chemin du Moulin Rose » : non — reste ambiguous).

Traçage (request_count, last_request_at) — chaque appel sur une adresse est compté (y compris les hits de cache), pour une facturation future du service.


3.11 Import enrichi ligne à ligne (mapping de colonnes + région)

Repart du fichier complet : on mappe les colonnes (le service propose un rôle par colonne, vous ajustez), puis chaque ligne ressort avec un verdict de délivrabilité sur son e-mail, un enrichissement entreprise, et son rattachement géographique (région + département). Résultat exportable en CSV (fichier d'origine + colonnes ajoutées).

Le flux est en deux temps (aperçu/confirmation, puis traitement) :

1. Aperçu + pré-matchingPOST /imports/preview - Corps = CSV brut (Content-Type: text/csv). Param filename. Max 20 000 lignes. - La 1ʳᵉ ligne est l'en-tête. Le service déduit, par colonne, un type de champ et une famille de traitement (modèle à 2 axes), et ne traite rien (statut draft). - Type de champ (~23, groupés) : contact (email, phone, phone_mobile, phone_fixe) · entreprise (website, siren, siret, vat, naf, company_name) · personne (full_name, first_name, last_name, civility, title) · adresse (address_full, address, address_complement, postal_code, city, country) · divers (keep). full_name repère un nom non séparé (« Nom complet », « Contact », « DUPONT Jean »). - Famille de traitement (treatment) — ce qu'on fait de la colonne : - 🔑 driver (entrée de travail) : déclenche un enrichissement externe → email (délivrabilité), website/siren/siret (entreprise via site ou registre) ; - 🧹 normalize (à traiter) : structure la valeur en place → full_name/title (civilité §6), address*/postal_code/city (BAN §3.10), phone* (E.164), siren/siret/vat (validation) ; - 📦 keep : conservée telle quelle. Le treatment proposé = la famille du type ; surchargeable par colonne (ex. désactiver un enrichissement coûteux en passant une colonne driver à keep). - Réponse : { job, columns[], sample[], roles[] } où chaque colonne = {index, header, role, group, family, treatment, source, confidence} et roles[] = le catalogue {role, group, family, label}. bash curl -X POST 'https://companydata.scale2sell.company/imports/preview?filename=contacts.csv' \ -H 'X-API-Key: VOTRE_CLE' -H 'Content-Type: text/csv' --data-binary @contacts.csv

2. LancementPOST /imports/{id}/run - Corps JSON : { "mapping": [ {"index":0,"role":"email","treatment":"driver"}, … ], "smtp": true, "llm": false }. Le mapping est celui confirmé ; treatment optionnel (défaut = famille du rôle). smtp défaut true (délivrabilité). - Aucune entrée n'est obligatoire : un import peut ne faire que normaliser (séparer des noms, formater des téléphones) sans aucun enrichissement externe. L'import passe pendingrunningdone. Relançable après un failed. bash curl -X POST 'https://companydata.scale2sell.company/imports/ID/run' \ -H 'X-API-Key: VOTRE_CLE' -H 'Content-Type: application/json' \ -d '{"mapping":[{"index":3,"role":"email"},{"index":4,"role":"website"},{"index":5,"role":"postal_code"}]}'

Traitement par ligne — pour chaque ligne : l'e-mail mappé est validé (§3.1, sonde SMTP selon smtp) ; le domaine (colonne website, sinon le domaine de l'e-mail pro) est enrichi (§3.3) ; la région/département est dérivée du code INSEE de l'entreprise enrichie (le plus fiable — Corse 2A/2B, outre-mer), avec repli sur le code postal (colonne postal_code du fichier si l'entreprise n'a pas été enrichie). Ainsi chaque ligne porte un rattachement, même sans domaine d'entreprise.

Civilité (service §6) — si une colonne de nom non séparé (full_name) est détectée — ou un seul champ nom contenant les deux —, la ligne est séparée de force en first_name / last_name (avec détection d'inversion « NOM Prénom »), et le sexe est inféré (INSEE). Si une colonne titre/fonction (title) est présente, une salutation intelligente est ajoutée en plus de la salutation simple (« Avocate » → Maître, « Présidente » → Madame la Présidente). Un prénom épicène (Dominique…) ressort UNDETERMINED sans salutation (à valider), jamais deviné.

Consultation & export - GET /imports/{id}/rows?status=&region=&page=&per_page= — les lignes traitées. Chaque ligne : le socle typé (email, email_status, company_name, siren, city, postal_code, citycode, enriched, first_name, last_name, gender, salutation, smart_salutation, region, department…) plus outputs (bloc extensible des normaliseurs : phone* {value E.164, national, type, status}, adresse {value, street, postcode, city, citycode, complement, status resolved/ambiguous, key}, siren/siret/tva {value, status}) et to_validate (nb de cellules à valider — F5). - GET /imports/{id}/export.csvfichier d'origine + colonnes ajoutées (prénom, nom, sexe, salutation, salutation_intelligente, téléphone_e164, téléphone_type, adresse_normalisée, adresse_statut, email_validé, email_statut, email_score, entreprise, siren, ville, code_postal, région, département, département_code, enrichi, à_valider). UTF-8 + BOM (Excel). - GET /imports liste les deux modes (mode = scan|rows) ; DELETE /imports/{id} supprime l'import et ses lignes (entreprises/validations sous-jacentes conservées).

Complétude : à la fin, chaque ligne a un verdict e-mail (deliverable/risky/ undeliverable/untestable — rappel §4 : Gmail/Orange & co restent untestable par nature) et, si un domaine d'entreprise était présent, un enrichissement (enriched=true). Une ligne sans domaine pro (e-mail grand public seul) reste enriched=false mais peut porter une région via son code postal.

3.12 GET /validate/phone — validation de téléphone (international)

Validation hors-ligne via libphonenumber (données Google embarquées, déterministe, tous pays, sans coût ni réseau) : validité, format E.164 / national / international, type de ligne, pays, indicatif, localisation, fuseau(x) horaire(s) et opérateur d'origine.

Param Type Défaut Description
number string requis numéro (national ou +E.164)
region string FR pays par défaut si le numéro n'a pas d'indicatif
hlr bool false dip HLR/SS7 opt-in : opérateur actuel (portage) + état live de la ligne
refresh bool false ignorer le cache HLR
curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/validate/phone?number=06%2012%2034%2056%2078"
{
  "input": "06 12 34 56 78", "valid": true, "possible": true,
  "e164": "+33612345678", "national": "06 12 34 56 78", "international": "+33 6 12 34 56 78",
  "type": "mobile", "country": "FR", "country_code": 33,
  "location": "France", "timezones": ["Europe/Paris"], "carrier": "Orange France"
}

typemobile | fixe | fixe_ou_mobile | voip | gratuit | surtaxé | coût_partagé | personnel | pager | uan | messagerie | unknown. Un numéro invalide est signalé (valid:false), jamais « corrigé » au hasard.

Couche HLR (hlr=true, opt-in). Le carrier gratuit est l'opérateur d'origine (bloc de numérotation) — faux si le numéro a été porté. Avec hlr=true, un dip IPQualityScore ajoute un bloc hlr avec l'opérateur servant réel (dérivé du MCC/MNC, portage inclus, table mondiale 238 pays) et l'état de la ligne :

"hlr": {"available": true, "current_carrier": "SFR", "allocation_carrier": "Orange France",
        "ported": true, "active": true, "active_status": "Active Line", "mcc": "208", "mnc": "10",
        "cached": false, "source": "ipqs"}

Dormant si non configuré (available:false, reason), mis en cache 90 j (jamais re-payé), aucun appel payant sur un numéro invalide.

POST /validate/phone/batch : { "items": [{number, region?}], "region"?, "hlr"? } (≤ 1000 ; ≤ 100 avec hlr).

3.13 GET /find/email — Email Finder (e-mail pro d'un contact)

Devine l'e-mail professionnel le plus probable d'une personne à partir de nom + domaine, avec une confiance explicite (jamais « délivrable » sans preuve).

Param Type Défaut Description
domain string requis domaine de l'entreprise
name string nom complet (Jean Dupont) ou
first_name/last_name string …champs séparés
smtp bool false vérifier en SMTP (RCPT, sans envoi)
curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/find/email?name=Jean%20Dupont&domain=acme.fr"
{
  "found": true, "email": "jean.dupont@acme.fr", "confidence": "verified", "method": "feedback",
  "pattern": "first.last", "provider_kind": "workspace", "has_mx": true, "mx_provider": "google",
  "first_name": "Jean", "last_name": "Dupont", "domain": "acme.fr",
  "alternatives": [{"pattern": "flast", "email": "jdupont@acme.fr"}]
}

Confiance (confidence) : verified (prouvé — soit un engagement réel remonté par la boucle bounce/deliverable, soit une sonde SMTP concluante) > pattern (correspond au motif appris du domaine) > guess. Un candidat déjà bouncé est écarté ; un candidat déjà délivrable confirmé est verified sans sonde — seul moyen fiable pour Gmail / Google Workspace (provider_kind distingue la boîte grand public du domaine d'entreprise). On ne fabrique jamais d'envoi pour vérifier.

3.14 GET /enrich/siren — enrichissement par SIREN/SIRET

Entrée = SIREN (9) ou SIRET (14, tronqué au siège). Renvoie l'identité légale (registre DINUM : raison sociale, adresse siège, NAF, effectif, création, dirigeants, finances, TVA, état actif/radié) et, via un pont SIREN → domaine, le logo puis la fiche complète en option. (Le sens inverse — domaine → SIREN — se fait via GET /enrich?domain=.)

Param Type Défaut Description
siren string requis SIREN (9) ou SIRET (14)
domain_hint string domaine connu ; validé avant adoption
resolve_domain enum referential referential (rapide) · web (recherche, plus lent) · off
logo bool true résoudre le logo si un domaine est trouvé
full bool false enrichissement complet du domaine (écrit le référentiel)
reinject bool false (mode web) enrichir en fiches les domaines découverts absents de la base
refresh bool false ignorer le cache
curl -H "X-API-Key: $KEY" \
  "https://companydata.scale2sell.company/enrich/siren?siren=882000995&resolve_domain=web"
{
  "siren": "882000995", "active": true,
  "identity": {"legal_name": "SAINT ELOI HOLDING", "naf": "70.10Z", "city": "MARSEILLE",
               "dirigeants": [{"name": "…", "role": "Président"}], "finances": {"…": "…"}},
  "registry_url": "https://annuaire-entreprises.data.gouv.fr/entreprise/882000995",
  "domain": "scale2sell.company", "domain_source": "referential", "domain_confidence": "high",
  "domains": [
    {"domain": "scale2sell.company", "source": "referential", "confidence": "high"},
    {"domain": "we-upgrade.fr", "source": "search", "confidence": "medium"}
  ],
  "image_url": "…", "image_source": "logodev", "candidates": [], "reinjected": []
}

Cascade de résolution du domaine. Le mode web fusionne toutes les sources : référentiel (fiche déjà enrichie, high) + domain_hint validé (site_siren == siren) + recherche « grounded » (métamoteur SearXNG auto-hébergé + le LLM identifie le domaine officiel parmi de vrais résultats — anti-hallucination ; ex. OVH → ovhcloud.com) + repli slugs. Renvoie une liste classée domains: [{domain, source, confidence}] (plusieurs domaines possibles — ex. marque.fr + marque.com, marques d'un groupe) ; domain = tête de liste. Confiance : SIREN prouvé par le site → high, choix LLM grounded → medium. domain:null (holding sans site) = réponse nominale, pas une erreur. Le mode referential (défaut) reste un court-circuit rapide.

Réinjection (reinject=true, mode web) : les domaines découverts absents du référentiel sont enrichis en fiches (les marques manquantes du groupe entrent dans la base) ; reinjected liste les domaines créés.

3.15 Clean Review — nettoyage du référentiel (règles + arbitres IA)

Un moteur de règles détecte les incohérences des fiches (nom / SIREN / adresse / doublons / réseaux…), et des arbitres LLM (Sonnet) tranchent les cas ambigus : auto-application si la confiance est haute et l'action réversible, sinon reco pré-remplie (★ IA) pour un clic humain.

Scan & suggestions - POST /review/scan?mode=incremental|full — (re)calcule les alertes du référentiel. - GET /review/suggestions?status=&rule=&per_page= — liste les alertes (pending/accepted/ rejected/stale/obsolete). - POST /review/suggestions/{id}/accept · /reject — trancher manuellement.

Arbitres IA (chacun : auto=true applique si confiance haute + réversible ; background=true pour vider le stock) : - POST /review/arbitratedispatcher unifié : arbitre en un appel adresse (+ normalisation) + SIREN + doublons. - POST /companies/{domain}/siren/arbitrate · POST /review/siren/arbitrateSIREN mal attribué : compare l'identité de la fiche au nom légal du SIREN au registreclear (vide le SIREN faux, réversible) si l'entreprise du registre est sans rapport (homonyme / erreur), sinon keep. Distingue « marque ≠ raison sociale » (normal) d'une vraie erreur. - POST /review/duplicate/arbitratedoublons (duplicate_name/duplicate_company) : même entité (reco fusion + domaine canonique) vs homonymes distincts (classe l'alerte faux positif). - POST /companies/{domain}/address/arbitrate · POST /review/address/arbitrateadresse : tranche site / registre / BAN à partir du texte du site ; POST /review/address/normalize-arbitrate pour la file de normalisation BAN.

Chaque arbitrage trace sa décision (choix, confiance, rationale, modèle) sur l'alerte, pour audit et pré-remplissage de la revue.

4. Limites assumées (à connaître avant d'intégrer)

Ces limites sont volontaires : on préfère un untestable honnête à un faux deliverable.

En pratique : pour du nettoyage, on retire les invalid/undeliverable/jetables (fiable à 100 %) et on garde les untestable grand public (statistiquement réels).


5. Intégration

  1. Clé API côté serveur uniquement (jamais exposée côté client public).
  2. Par contact : GET /validate/email (délivrabilité) et GET /enrich sur le domaine (identité + logo). Les deux mettent en cache.
  3. Piloter sur status/score (validation) et status/enrichment_level (enrichissement).
  4. Afficher image_url telle quelle (URL absolue, endpoint public sans clé).
  5. Respecter le cache : pas de refresh=true en boucle ; utiliser les endpoints batch (≤ 50) pour du volume.
  6. Générer un client depuis GET /openapi.json (toujours à jour).

6. Service « Civilité & Identité » (/v1)

Service déterministe d'identité civile pour la personnalisation des communications : à partir des données d'un contact — même quand nom et prénom ne sont pas séparés — il retourne prénom/nom séparés, sexe estimé (INSEE), salutation simple et intelligente (par workspace). Aucun LLM dans le chemin nominal (réservé aux titres rares, §6.5). Chaque valeur porte source/confidence, et la referentialVersion du fichier prénoms.

6.1 POST /v1/civility — un contact

Corps : fullName ou firstName/lastName, plus optionnels email, jobTitle, salutation, attributes (champs custom du workspace), workspaceId, configOverride (config inline).

curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"workspaceId":"ws_123","fullName":"DUPONT-MARTIN Marie","jobTitle":"Avocate associée","attributes":{"is_avocat":"oui"}}' \
  https://companydata.scale2sell.company/v1/civility
{
  "firstName": "Marie", "lastName": "Dupont-Martin",
  "detectedOrder": "NOM_PRENOM", "method": "CASING", "nameConfidence": 0.9,
  "detectedTitle": null,
  "gender": { "value": "FEMALE", "pFemale": 0.99, "confidence": 0.98, "source": "INFERE", "needsValidation": false },
  "salutation": "Madame", "smartSalutation": "Maître",
  "needsValidation": null, "referentialVersion": "insee-2024"
}

6.2 POST /v1/civility/batch — lot (≤ 5000)

{ "workspaceId"?, "configOverride"?, "contacts": [ {…}, … ] }{ count, results[], referentialVersion }. Une seule résolution de config pour tout le lot (un contact peut porter son propre configOverride).

6.3 PUT /v1/workspaces/{workspaceId}/salutation-config — config workspace

Pilote la salutation intelligente sans redéploiement, cloisonnée par workspace.

{
  "rules": [
    { "fields": ["is_avocat"], "form": { "neutre": "Maître" } },
    { "field": "grade", "equals": "colonel", "form": { "homme": "Mon Colonel", "femme": "Mon Colonel" } }
  ],
  "titleOverrides": { "colonel": { "homme": "Mon Colonel", "femme": "Mon Colonel" } }
}

6.4 Non-écrasement des sources

Chaque valeur porte une source ordonnée : DECLARE (saisi humain) > CONFIRME (validé) > INFERE (calculé). Le service ne propose jamais d'écraser une source plus forte.

6.5 POST /v1/civility/suggest — titres rares (optionnel)

Proposition à la demande pour une fonction rare (jobTitle), via modèle de langage : best-effort, jamais automatique, jamais pour le sexe (si le sexe est inconnu, renvoie les deux formes en alternatives). Peut renvoyer { "suggestion": null, "alternatives": [] }.

6.6 Référentiel INSEE

Table prénom → P(féminin) construite depuis le Fichier des prénoms de l'INSEE (open data, Licence Ouverte/Etalab) : national, toutes années, plancher d'effectif 100, ~11 600 prénoms. Rafraîchissable indépendamment du code via scripts/build_prenoms_insee.py ; version exposée dans chaque réponse (referentialVersion).