Vous savez ce qu'est une zone de chalandise. Maintenant vous voulez en tracer une, autour d'une adresse précise, et pas sur un schéma. Cet article est le mode d'emploi. Il propose deux chemins vers le même contour. Le premier passe par la démo publique, sans compte ni ligne de code, avec une réponse en quelques secondes. Le second passe par l'API, en quatre étapes, avec les appels exacts et un script Python qui écrit le contour et les sites en GeoJSON. Si la théorie vous manque (définition, zones primaire et secondaire, choix de la durée), lisez d'abord comprendre et exploiter la zone de chalandise.

Sans code : la démo

Le plus rapide est encore de regarder un contour avant d'écrire quoi que ce soit. Vous saisissez l'adresse d'un point de vente, vous choisissez 5, 10, 15, 20 ou 30 minutes, puis un mode de déplacement (voiture, vélo ou marche), et la carte affiche l'isochrone calculée sur le réseau routier réel. Ce n'est pas un cercle tracé au compas. Sous la carte, vous trouvez la liste des autres sites du réseau qui tombent à l'intérieur, chacun avec sa distance, et la fiche IRIS de l'adresse saisie. Le référentiel interrogé est fictif. Il contient mille restaurants imaginaires répartis sur la France, et il sert surtout à montrer comment des zones voisines se recouvrent.

La démo affiche ce chiffre sous la carte : population, ménages, ménages pauvres et niveau de vie moyen de la zone ; l'étape 4 montre comment l'obtenir par l'API.

Calculer une zone de chalandise dans la démo, sans compte et sans carte bancaire.

Avec l'API : quatre étapes

Les exemples qui suivent utilisent la base https://api.trustydata.app/services/v1, une clé d'API en en-tête Authorization: Bearer et la bibliothèque requests. Mettez la clé dans la variable d'environnement TRUSTYDATA_API_KEY plutôt que dans le fichier. Sinon elle finit dans le dépôt Git, et une clé qui a fuité se révoque toujours trop tard.

1. Géocoder le point de vente

Tout part d'une adresse propre. POST /address/verify la confronte au référentiel BAN officiel et renvoie les candidats classés par score, avec l'identifiant du document adresse et le code INSEE de la commune. À partir du plan Starter, la réponse ajoute la position en WGS84 et en Lambert 93.

import os

import requests

BASE = "https://api.trustydata.app/services/v1"
CLE = os.environ["TRUSTYDATA_API_KEY"]
ENTETES = {"Authorization": f"Bearer {CLE}"}

reponse = requests.post(
    f"{BASE}/address/verify",
    json={"q": "1 rue Scribe 75009 Paris", "max_results": 3},
    headers=ENTETES,
    timeout=30,
)
reponse.raise_for_status()
for candidat in reponse.json()["matches"]:
    print(candidat["score"], candidat["adresse"], candidat["id"])

Regardez le score et le verdict avant d'aller plus loin. Une adresse mal saisie qui remonte un candidat moyen décale tout le contour, et personne ne s'en apercevra plus après. Gardez aussi l'id renvoyé. C'est lui qui ouvre la fiche complète de l'adresse à l'étape 4, sans repayer une recherche.

L'API traite une adresse par requête. Pour un réseau entier, la boucle se fait chez vous, dans un script ou un pipeline ETL. La dernière section de cet article montre comment.

2. Créer votre zone de chalandise par durée de trajet

Le contour et les sites qu'il contient se demandent en un seul appel à GET /locations/search, dans votre propre référentiel de lieux, celui que vous avez versé au préalable avec vos boutiques ou vos agences.

Trois paramètres définissent la zone. duree fixe le temps de trajet en minutes, de 1 à 30. mode choisit le moyen de déplacement, auto, velo ou pieton. polygone=true demande le contour lui-même, renvoyé en GeoJSON dans portee.polygone. Le centre se donne soit en lat et lon, soit en adresse libre, jamais les deux.

curl "https://api.trustydata.app/services/v1/locations/search?referentiel=mon-reseau&adresse=1+rue+Scribe+75009+Paris&duree=15&mode=auto&polygone=true" \
  -H "Authorization: Bearer $TRUSTYDATA_API_KEY"

duree exclut rayon. On demande une portée en minutes ou une portée en kilomètres, pas les deux. La portée par durée (duree, mode, polygone) relève du plan Growth. La portée par rayon existe dès Starter et reste utile pour une livraison, où la contrainte est vraiment une distance.

Sur le choix de la durée, un conseil : elle dépend de l'activité, pas de l'habitude. Quinze minutes en voiture font deux ou trois kilomètres dans Paris et parfois vingt le long d'une nationale. La même valeur ne recouvre jamais la même surface deux fois. C'est bien pour cela qu'on raisonne en minutes.

3. Lire le contour et les sites dedans

La réponse tient en quatre blocs. point_central dit où la recherche a réellement porté, avec un champ precision qui vaut coordonnees, adresse ou commune. Si vous avez saisi « Lyon » au lieu d'une adresse complète, le centre est celui de la commune et le contour perd son sens. portee récapitule ce qui a sélectionné les résultats et contient le contour en GeoJSON. resultats liste les sites retenus, du plus proche au plus lointain, avec leur distance_m à vol d'oiseau. attribution porte la mention OpenStreetMap, à afficher dès qu'une isochrone est calculée.

import json
import os

import requests

BASE = "https://api.trustydata.app/services/v1"
CLE = os.environ["TRUSTYDATA_API_KEY"]
ENTETES = {"Authorization": f"Bearer {CLE}"}

reponse = requests.get(
    f"{BASE}/locations/search",
    params={
        "referentiel": "mon-reseau",
        "adresse": "1 rue Scribe 75009 Paris",
        "duree": 15,
        "mode": "auto",
        "polygone": "true",
        "limit": 200,
    },
    headers=ENTETES,
    timeout=30,
)
reponse.raise_for_status()
data = reponse.json()

centre, portee = data["point_central"], data["portee"]
print(f"Centre  : {centre['adresse_resolue']} ({centre['precision']})")
print(f"Portée  : {portee['duree_min']} min en {portee['mode']}")

# Le contour, tel quel : GeoJSON relisible par QGIS, Leaflet ou GeoPandas.
with open("zone.geojson", "w", encoding="utf-8") as fichier:
    json.dump(portee["polygone"], fichier, ensure_ascii=False)

# Les sites du réseau qui tombent dedans, en points.
sites = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "geometry": {
                "type": "Point",
                "coordinates": [site["longitude"], site["latitude"]],
            },
            "properties": {
                "id_externe": site["id_externe"],
                "nom": site["nom_public"],
                "distance_m": site["distance_m"],
            },
        }
        for site in data["resultats"]
        if site["latitude"] is not None and site["longitude"] is not None
    ],
}
with open("sites.geojson", "w", encoding="utf-8") as fichier:
    json.dump(sites, fichier, ensure_ascii=False)

print(f"{len(sites['features'])} sites dans la zone")
print(data.get("attribution", ""))

Les deux fichiers s'ouvrent dans QGIS, se chargent dans GeoPandas avec geopandas.read_file() ou se passent à L.geoJSON() côté navigateur. Le contour pèse entre 10 et 20 Ko. Il tient dans une réponse HTTP, pas dans une URL, et c'est pour cela que polygone=true reste optionnel au lieu d'être renvoyé par défaut.

Un piège à connaître : le tri des résultats reste à vol d'oiseau, même quand la portée est une isochrone. Un site de l'autre côté d'un fleuve peut donc apparaître avant un site plus loin à vol d'oiseau mais plus rapide à atteindre. Si l'ordre routier compte pour vous, distances=true ajoute duree_s et distance_routiere_m aux 25 premiers résultats, et vous retriez sur ces valeurs.

Ces sites sont ceux de votre référentiel « Mes lieux » ; le même référentiel sert aussi à afficher le point de vente le plus proche d'un client.

4. Chiffrer la zone, puis enrichir les adresses

Reste à qualifier le territoire. GET /address/view/{id} reprend l'identifiant obtenu à l'étape 1, ou celui que point_central.id renvoie quand le centre a été résolu depuis une adresse, et rend la fiche complète. geocoding.code_iris et geocoding.nom_iris arrivent avec le plan Growth. Le bloc statistical_grid, le carreau INSEE Filosofi de 200 mètres, demande le plan Business.

Ce bloc enchaîne sur le précédent et réutilise requests, BASE, ENTETES et centre. Sur un plan Growth, statistical_grid est absent de la réponse plutôt que vide. D'où le .get() : un accès direct lèverait KeyError.

detail = requests.get(
    f"{BASE}/address/view/{centre['id']}",
    headers=ENTETES,
    timeout=30,
).json()

geo = detail.get("geocoding") or {}
carreau = detail.get("statistical_grid") or {}
print(geo.get("code_iris"), geo.get("nom_iris"))
if carreau:
    print(carreau["ind"], "personnes et", carreau["men"], "ménages sur le carreau")
else:
    print("Carreau INSEE non disponible : passez au plan Business pour l'obtenir.")

Cet enrichissement-là se fait par adresse. Pour la zone entière, c'est un autre appel, POST /zone/stats, avec le même centre, la même durée et le même mode que l'étape 3 : le contour est déjà calculé côté API, il n'est pas recalculé.

stats = requests.post(
    f"{BASE}/zone/stats",
    headers=ENTETES,
    json={"adresse": "1 rue Scribe 75009 Paris", "duree": 15, "mode": "auto"},
    timeout=30,
).json()

print(stats["population"], "habitants,", stats["menages"], "ménages,",
      stats["menages_pauvres"], "sous le seuil de pauvreté")
print("niveau de vie moyen :", stats["niveau_de_vie_moyen"], "€ par personne et par an")
print(stats["iris_couverts"], "quartiers IRIS touchés,",
      stats["carreaux"]["estimes"], "carreaux estimés sur", stats["carreaux"]["total"])
print(stats["attribution"])

Les chiffres sont des sommes des carreaux INSEE Filosofi de 200 mètres qui tombent dans le contour, pondérées par la part de chaque carreau dans la zone : un carreau à cheval sur la limite compte pour ce qui est dedans. Le niveau de vie est une moyenne par personne, la seule grandeur de revenu qu'un carroyage permet d'additionner. carreaux.estimes dit combien de carreaux portent des valeurs imputées par l'INSEE au titre du secret statistique ; hors des villes denses c'est souvent la majorité, et c'est la norme du fichier, pas une faiblesse de votre zone. La mention attribution se reproduit avec tout chiffre affiché. Un contour que vous avez déjà, celui de l'étape 3 ou un contour dessiné à la main, s'envoie tel quel : json={"polygone": contour}. Sources : carreaux Filosofi 200 m (INSEE, millésime 2021), Contours IRIS (IGN, édition 2026), temps de trajet sur OpenStreetMap (ODbL).

Le chiffre dit qui habite la zone, pas qui y est déjà client. C'est le complément qui rend l'exercice utile : géocodez votre fichier client adresse par adresse, récupérez le code IRIS et le carreau de chacun, comptez ceux qui tombent dans le polygone de l'étape 3 — un test point-dans-polygone, une ligne avec GeoPandas, ou ST_Contains avec PostGIS — et rapportez-les à la population rendue par /zone/stats. Vous lisez alors un taux de pénétration, la part de la population qui est cliente, et c'est lui qui départage deux emplacements.

Attention, centre["id"] vaut null quand le centre a été donné en coordonnées ou résolu au niveau d'une commune. Testez-le avant d'enchaîner.

Créer un calculateur de zone de chalandise pour votre réseau

Les quatre étapes traitent un point de vente. Pour un réseau, vous itérez. Vous listez les sites de votre référentiel, vous appelez /locations/search une fois par site avec la même durée et le même mode, et vous stockez chaque contour. Vous obtenez une couche de polygones à superposer dans QGIS ou dans un entrepôt PostGIS.

C'est là que l'exercice prend de l'intérêt. L'intersection de deux contours mesure le recouvrement entre deux magasins voisins, cette bande où le prospectus est distribué deux fois et où le client qui entre chez l'un aurait pu entrer chez l'autre. L'union des contours dessine la couverture du réseau. Son complément montre les trous, qui sont soit une occasion d'implantation, soit un territoire laissé à un concurrent. Relancez le calcul chaque trimestre et vous suivez l'effet des ouvertures et des fermetures sans refaire l'étude à la main.

Le détail du produit (référentiels de sites, capacité incluse par plan, étiquettes, horaires) est décrit sur la page zone de chalandise.

Données BAN et INSEE (sources publiques). Itinéraires © contributeurs OpenStreetMap, licence ODbL.