On va où ?API

API Itinéraires / Tutoriel

Calculer un itinéraire vélo sécurisé avec une API

Tutoriel publié le · curl, JavaScript, Python, MapLibre GL JS, MCP

Vous allez calculer un vrai trajet à vélo dans Paris, lire ce qui le rend sûr ou non, l’afficher sur une carte, puis gérer les erreurs comme en production. Il vous faut une clé gratuite, un terminal et, au choix, Node.js ou Python.

Obtenir une clé gratuite Documentation de l’API

1. Le plus court n’est pas le plus sûr

Un calculateur d’itinéraire généraliste cherche le trajet le plus court, en distance ou en temps. En ville, ce trajet passe souvent par les grands axes : ce sont les rues les plus directes, et aussi celles où la circulation motorisée est la plus dense et la plus rapide. Pour une voiture, c’est le bon choix. Pour un vélo, c’est souvent le moins agréable.

Ce qui compte à vélo se voit rarement dans la durée affichée :

Un bon calcul d’itinéraire vélo fait donc deux choses : il propose plusieurs tracés, et il dit ce que vaut chacun. C’est le principe de l’API Itinéraires « On va où ? » : une requête renvoie jusqu’à trois variantes, safe, balanced et fast, avec pour chacune la part de pistes cyclables, de grands axes et de revêtements non goudronnés, et le dénivelé. Votre application choisit, ou laisse choisir, en connaissance de cause.

« Sécurisé » décrit ici l’infrastructure empruntée, telle que la cartographie OpenStreetMap la connaît. C’est un critère de choix solide : montrez les indicateurs à vos utilisateurs plutôt que de leur promettre un trajet sans risque.

2. Obtenir une clé gratuite

  1. Ouvrez console.onvaou.app et créez un compte avec votre adresse e-mail. Un code de vérification vous est envoyé.
  2. Dans le menu Clés, nommez la clé (par exemple « tutoriel ») puis cliquez sur Créer la clé.
  3. Copiez-la tout de suite : elle commence par ovo_live_ et ne sera plus affichée.

Le palier Découverte est gratuit et sans carte bancaire : 10 000 requêtes par mois, 1 requête par seconde, pour développer et pour un usage non commercial. L’usage commercial commence au palier Solo, à 19 € HT par mois. Toute l’Europe est couverte, dans chaque palier.

Rangez la clé dans une variable d’environnement plutôt que dans votre code. Sous macOS, Linux ou WSL :

export OVO_API_KEY="ovo_live_..."

Sous Windows, dans PowerShell :

$env:OVO_API_KEY = "ovo_live_..."

La clé reste sur votre serveur. L’API n’accepte pas les appels venus d’un navigateur (pas de CORS), et une clé placée dans une page web ou dans une application mobile peut être lue par n’importe qui. La section carte montre comment faire passer l’appel par votre serveur.

3. Premier appel en curl

Un seul point d’accès, POST https://api.onvaou.app/v1/routes, avec la clé dans l’en-tête x-api-key. Calculons un trajet à vélo de l’Hôtel de Ville de Paris à Vincennes :

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[2.3522,48.8566],"destination":[2.4392,48.8474],"mode":"bike"}'

Seuls origin et destination sont obligatoires. mode vaut bike par défaut, et les consignes de guidage sont en français sauf si vous passez language (en, de, es, it, nl ou pt).

Les coordonnées s’écrivent [longitude, latitude], dans cet ordre, comme en GeoJSON. C’est l’inverse de ce qu’affichent la plupart des sites de cartographie. Si vous les inversez, le point tombe dans l’océan Indien et l’API répond 422 out_of_coverage.

4. Lire la réponse et choisir une variante

Voici la réponse réelle obtenue le 21 septembre 2026, raccourcie (coordonnées, profil d’altitude et étapes) :

{
  "mode": "bike",
  "routes": [
    {
      "variants": ["safe", "balanced", "fast"],
      "distanceM": 7159,
      "durationS": 1525,
      "ascentM": 144,
      "descentM": 127,
      "indicators": {
        "cyclewayShare": 0.773,
        "unpavedShare": 0,
        "mainRoadShare": 0.036,
        "motorwayShare": 0
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [[2.352139, 48.857037], [2.352162, 48.857078], [2.352188, 48.857125], "..."]
      },
      "elevationProfile": [[0, 39], [21, 39], [39, 39], "..."],
      "steps": [
        { "instruction": "Démarrez en direction du Nord", "name": null, "distanceM": 23, "durationS": 26, "type": 11 },
        { "instruction": "Tournez à droite", "name": null, "distanceM": 12, "durationS": 7, "type": 1 },
        { "instruction": "Tournez à gauche", "name": null, "distanceM": 4, "durationS": 2, "type": 0 },
        { "instruction": "Tournez à droite sur Rue de Rivoli", "name": "Rue de Rivoli", "distanceM": 717, "durationS": 143, "type": 1 },
        "..."
      ]
    }
  ],
  "usage": { "month": "2026-09", "requests": 1, "quota": 10000, "plan": "decouverte" },
  "attribution": "© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors"
}
ChampCe qu’il vous dit
variantsLes étiquettes portées par ce tracé : safe (le plus de pistes cyclables et le moins de grands axes), fast (le plus rapide), balanced (le meilleur compromis restant).
distanceM, durationSDistance en mètres, durée estimée en secondes.
ascentM, descentMMontée et descente cumulées, en mètres.
indicators.cyclewaySharePart de la distance sur piste cyclable, de 0 à 1.
indicators.mainRoadSharePart sur les grands axes.
indicators.unpavedSharePart sur revêtement non goudronné.
indicators.motorwaySharePart sur voie rapide (utile surtout à moto).
geometryLe tracé, en LineString GeoJSON, prêt pour une carte.
elevationProfileJusqu’à 100 points [distance en m, altitude en m], pour tracer un profil.
stepsLe guidage : consigne, nom de la voie, distance, durée et type de manœuvre (11 pour le départ, 0 à gauche, 1 à droite).
usageVotre consommation du mois, aussi donnée par les en-têtes X-Quota-Used et X-Quota-Limit.
attributionLa mention à afficher telle quelle près de l’itinéraire ; elle comprend le crédit des données OpenStreetMap.

Lecture de l’exemple : 7,2 km en 25 minutes, 144 m de montée cumulée, 77 % du trajet sur piste cyclable, moins de 4 % sur de grands axes et rien de non goudronné. Entre ces deux points, un seul itinéraire pertinent existe : il porte les trois étiquettes à la fois. L’API n’invente jamais de variante pour remplir la liste ; sur d’autres trajets, vous recevrez deux ou trois tracés distincts.

Choisir une variante

Une règle simple et honnête : proposez safe par défaut. Quand fast est un autre tracé, montrez l’écart (les minutes en plus, les points de pistes cyclables en plus) et laissez l’utilisateur trancher. Les exemples JavaScript et Python ci-dessous appliquent cette règle.

Pour ne garder que l’essentiel en ligne de commande, filtrez avec jq :

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[2.3522,48.8566],"destination":[2.4392,48.8474],"mode":"bike"}' \
  | jq '.routes[] | {variants, distanceM, durationS, ascentM, indicators}'

Pour une application mobile, "geometry": "polyline" renvoie le tracé en polyligne encodée (algorithme Google, précision 5), bien plus compacte : "okeiH{kjMGCIEGCGEEAF]CCAAp@yELy@DY@Cl@eE...".

5. En JavaScript (Node.js 18 ou plus récent)

Depuis la version 18, Node.js intègre fetch : aucune dépendance à installer. Enregistrez ce fichier sous itineraire.mjs (l’extension .mjs autorise await au premier niveau), puis lancez node itineraire.mjs.

itineraire.mjs

// itineraire.mjs : lancez « node itineraire.mjs » (Node.js 18 ou plus récent)
const API_URL = 'https://api.onvaou.app/v1/routes';
const apiKey = process.env.OVO_API_KEY;
if (!apiKey) {
  console.error('Définissez la variable OVO_API_KEY (clé gratuite sur https://console.onvaou.app).');
  process.exit(1);
}

const res = await fetch(API_URL, {
  method: 'POST',
  headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    origin: [2.3522, 48.8566], // [longitude, latitude] : Hôtel de Ville, Paris
    destination: [2.4392, 48.8474], // Vincennes
    mode: 'bike',
    language: 'fr',
  }),
});
const data = await res.json();
if (!res.ok) {
  console.error(`Erreur ${res.status} ${data.error} : ${data.message}`);
  process.exit(1);
}

const pct = (share) => (share == null ? '?' : `${Math.round(share * 100)} %`);
for (const route of data.routes) {
  const { cyclewayShare, mainRoadShare, unpavedShare } = route.indicators;
  console.log(
    `${route.variants.join(', ')} : ${(route.distanceM / 1000).toFixed(1)} km, ` +
      `${Math.round(route.durationS / 60)} min, +${route.ascentM ?? '?'} m, ` +
      `pistes cyclables ${pct(cyclewayShare)}, grands axes ${pct(mainRoadShare)}, ` +
      `non goudronné ${pct(unpavedShare)}`,
  );
}

// Proposer « safe » par défaut, et montrer l'écart quand « fast » est un autre tracé.
const pick = (variant) => data.routes.find((r) => r.variants.includes(variant)) ?? data.routes[0];
const safe = pick('safe');
const fast = pick('fast');
console.log(`Itinéraire proposé : ${safe.variants.join(', ')}`);
if (safe !== fast) {
  const extraMin = Math.round((safe.durationS - fast.durationS) / 60);
  const extraLanes = Math.round(((safe.indicators.cyclewayShare ?? 0) - (fast.indicators.cyclewayShare ?? 0)) * 100);
  console.log(`Le plus sûr prend ${extraMin} min de plus, avec ${extraLanes} points de pistes cyclables en plus.`);
}
console.log(data.attribution);

Sortie pour notre trajet :

safe, balanced, fast : 7.2 km, 25 min, +144 m, pistes cyclables 77 %, grands axes 4 %, non goudronné 0 %
Itinéraire proposé : safe, balanced, fast
© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors

Quand fast est un autre tracé, le script ajoute une ligne du type « Le plus sûr prend N min de plus, avec M points de pistes cyclables en plus ».

6. En Python (requests)

Installez requests avec pip install requests, enregistrez ce fichier sous itineraire.py, puis lancez python3 itineraire.py.

itineraire.py

# itineraire.py : pip install requests, puis python3 itineraire.py
import os
import sys

import requests

API_URL = "https://api.onvaou.app/v1/routes"
api_key = os.environ.get("OVO_API_KEY")
if not api_key:
    sys.exit("Définissez la variable OVO_API_KEY (clé gratuite sur https://console.onvaou.app).")

resp = requests.post(
    API_URL,
    headers={"x-api-key": api_key},
    json={
        "origin": [2.3522, 48.8566],  # [longitude, latitude] : Hôtel de Ville, Paris
        "destination": [2.4392, 48.8474],  # Vincennes
        "mode": "bike",
        "language": "fr",
    },
    timeout=30,
)
data = resp.json()
if not resp.ok:
    sys.exit(f"Erreur {resp.status_code} {data.get('error')} : {data.get('message')}")


def pct(share):
    return "?" if share is None else f"{round(share * 100)} %"


for route in data["routes"]:
    ind = route["indicators"]
    climb = "?" if route["ascentM"] is None else route["ascentM"]
    print(
        f"{', '.join(route['variants'])} : {route['distanceM'] / 1000:.1f} km, "
        f"{round(route['durationS'] / 60)} min, +{climb} m, "
        f"pistes cyclables {pct(ind['cyclewayShare'])}, grands axes {pct(ind['mainRoadShare'])}, "
        f"non goudronné {pct(ind['unpavedShare'])}"
    )


def pick(variant):
    return next((r for r in data["routes"] if variant in r["variants"]), data["routes"][0])


# Proposer « safe » par défaut, et montrer l'écart quand « fast » est un autre tracé.
safe, fast = pick("safe"), pick("fast")
print("Itinéraire proposé :", ", ".join(safe["variants"]))
if safe is not fast:
    extra_min = round((safe["durationS"] - fast["durationS"]) / 60)
    extra_lanes = round(((safe["indicators"]["cyclewayShare"] or 0) - (fast["indicators"]["cyclewayShare"] or 0)) * 100)
    print(f"Le plus sûr prend {extra_min} min de plus, avec {extra_lanes} points de pistes cyclables en plus.")
print(data["attribution"])

La sortie est la même que celle de la version JavaScript.

7. Afficher le tracé sur une carte MapLibre

geometry est une LineString GeoJSON : MapLibre GL JS l’affiche telle quelle. Comme la clé ne doit pas aller dans le navigateur, un petit serveur Node.js appelle l’API et transmet le résultat à la page. Placez ces deux fichiers dans le même dossier.

serveur.mjs

// serveur.mjs : node serveur.mjs, puis ouvrez http://localhost:3000
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const API_URL = 'https://api.onvaou.app/v1/routes';
const API_KEY = process.env.OVO_API_KEY;
if (!API_KEY) {
  console.error('Définissez la variable OVO_API_KEY (clé gratuite sur https://console.onvaou.app).');
  process.exit(1);
}
const MODES = new Set(['bike', 'ebike', 'scooter', 'wheelchair']);
const point = (text) => {
  const p = String(text ?? '').split(',').map(Number);
  return p.length === 2 && p.every(Number.isFinite) ? p : null;
};
const sendJson = (res, status, body) => {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
};

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  try {
    if (url.pathname === '/') {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(await readFile(new URL('./carte.html', import.meta.url)));
    } else if (url.pathname === '/itineraire') {
      const origin = point(url.searchParams.get('from'));
      const destination = point(url.searchParams.get('to'));
      const mode = url.searchParams.get('mode') ?? 'bike';
      if (!origin || !destination || !MODES.has(mode)) {
        sendJson(res, 400, { error: 'bad_request', message: 'Paramètres attendus : from et to (lon,lat), mode.' });
        return;
      }
      // La clé part d'ici, côté serveur : le navigateur ne la voit jamais.
      const api = await fetch(API_URL, {
        method: 'POST',
        headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({ origin, destination, mode, elevation: false, instructions: false }),
      });
      sendJson(res, api.status, await api.text());
    } else {
      sendJson(res, 404, { error: 'not_found' });
    }
  } catch (err) {
    sendJson(res, 502, { error: 'upstream', message: err.message });
  }
}).listen(3000, () => console.log('Carte : http://localhost:3000'));

carte.html

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Itinéraire vélo sécurisé</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<style>
  html, body, #map { height: 100%; margin: 0; }
  #info { position: absolute; top: 10px; left: 10px; padding: 8px 12px; background: #fff;
          border-radius: 8px; font: 14px system-ui, sans-serif; box-shadow: 0 1px 4px rgba(0, 0, 0, .25); }
</style>
</head>
<body>
<div id="map"></div>
<div id="info">Calcul de l'itinéraire...</div>
<script>
  const map = new maplibregl.Map({
    container: 'map',
    style: 'https://tiles.openfreemap.org/styles/positron', // fond de carte libre, sans clé
    center: [2.395, 48.852],
    zoom: 12,
    attributionControl: { compact: false }, // attribution toujours visible
  });

  map.on('load', async () => {
    const info = document.getElementById('info');
    const res = await fetch('/itineraire?from=2.3522,48.8566&to=2.4392,48.8474&mode=bike');
    const data = await res.json();
    if (!res.ok) {
      info.textContent = `Erreur : ${data.message || data.error}`;
      return;
    }

    const features = data.routes.map((route) => ({
      type: 'Feature',
      properties: { safe: route.variants.includes('safe') },
      geometry: route.geometry,
    }));
    map.addSource('routes', {
      type: 'geojson',
      data: { type: 'FeatureCollection', features },
      attribution: data.attribution, // mention renvoyée par l’API (dont © OpenStreetMap contributors)
    });
    map.addLayer({
      id: 'autres-variantes',
      type: 'line',
      source: 'routes',
      filter: ['==', ['get', 'safe'], false],
      paint: { 'line-color': '#8a9aa0', 'line-width': 4 },
    });
    map.addLayer({
      id: 'variante-sure',
      type: 'line',
      source: 'routes',
      filter: ['==', ['get', 'safe'], true],
      layout: { 'line-join': 'round', 'line-cap': 'round' },
      paint: { 'line-color': '#0d7f7a', 'line-width': 6 },
    });

    const safe = data.routes.find((route) => route.variants.includes('safe')) ?? data.routes[0];
    const bounds = new maplibregl.LngLatBounds();
    for (const coord of safe.geometry.coordinates) bounds.extend(coord);
    map.fitBounds(bounds, { padding: 40 });
    info.textContent = `Itinéraire sûr : ${(safe.distanceM / 1000).toFixed(1)} km, ` +
      `${Math.round(safe.durationS / 60)} min, ` +
      `${Math.round((safe.indicators.cyclewayShare ?? 0) * 100)} % de pistes cyclables`;
  });
</script>
</body>
</html>

Lancez node serveur.mjs puis ouvrez http://localhost:3000 : la variante sûre s’affiche en vert, les autres variantes éventuelles en gris, avec le résumé du trajet en haut à gauche.

8. Vélo électrique, trottinette et fauteuil roulant en une ligne

Le même appel sert d’autres véhicules : seul le corps de la requête change.

VéhiculeDans le corps de la requête
Vélo"mode": "bike" (par défaut)
Vélo électrique"mode": "ebike"
Trottinette"mode": "scooter"
Fauteuil roulant"mode": "wheelchair", "avoid": ["steep"]

En fauteuil roulant, l’itinéraire se limite à des pentes de 12 % au plus, abaissées à 6 % avec "avoid": ["steep"], à des bordures de 15 cm au plus et à des passages d’au moins 50 cm. "avoid": ["unpaved"] écarte en plus les revêtements non goudronnés. Un seul itinéraire est renvoyé, sans alternatives. Exemple réel, dans le centre de Lyon :

curl -sS -X POST https://api.onvaou.app/v1/routes \
  -H "x-api-key: $OVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":[4.8357,45.7640],"destination":[4.8420,45.7600],"mode":"wheelchair","avoid":["steep"],"elevation":false}'
{
  "mode": "wheelchair",
  "routes": [
    {
      "variants": ["safe", "balanced", "fast"],
      "distanceM": 942,
      "durationS": 661,
      "ascentM": 22,
      "descentM": 31,
      "indicators": { "cyclewayShare": 0, "unpavedShare": 0.005, "mainRoadShare": 0, "motorwayShare": 0 },
      "geometry": { "type": "LineString", "coordinates": [[4.835726, 45.763999], [4.835719, 45.763879], [4.835716, 45.763841], "..."] },
      "steps": [
        { "instruction": "Démarrez en direction du Sud", "name": null, "distanceM": 25, "durationS": 18, "type": 11 },
        { "instruction": "Tournez à gauche", "name": null, "distanceM": 9, "durationS": 5, "type": 0 },
        { "instruction": "Tournez à droite sur Rue de la République", "name": "Rue de la République", "distanceM": 299, "durationS": 215, "type": 1 },
        "..."
      ]
    }
  ],
  "attribution": "© On va où ? · © openrouteservice by HeiGIT · © OpenStreetMap contributors"
}

942 m en 11 minutes, 22 m de montée, 0,5 % de revêtement non goudronné. La moto (moto, avec avoid tolls ou highways) et la marche (foot) passent par le même appel.

9. Gérer les erreurs 429 et 422

Toutes les erreurs ont la même forme, { "error": "...", "message": "..." }. Votre code s’appuie sur error, qui est stable ; message s’adresse aux humains : il est en anglais par défaut, en français avec l’en-tête Accept-Language: fr ou "language": "fr" dans la requête, comme dans nos exemples. Un calcul en erreur n’est pas compté dans votre quota.

StatuterrorQue faire
429rate_limitedDébit du palier dépassé (par seconde, toutes les clés du compte ensemble). Attendre le nombre de secondes indiqué par l’en-tête Retry-After, puis réessayer.
429quota_exceededQuota mensuel du palier gratuit atteint. Inutile de réessayer : changer de palier, ou attendre le mois suivant.
422out_of_coverageUn point est hors d’Europe, ou longitude et latitude sont inversées. Demander un autre point.
422no_routeAucun itinéraire praticable pour ce véhicule entre ces points. Rapprocher les points d’une rue ou d’un chemin, ou changer de véhicule.
503engine_capacity, engine_unavailablePassager : réessayer un peu plus tard (après Retry-After quand il est fourni).
400, 401bad_*, too_long, invalid_keyCorriger la requête ou la clé : réessayer tel quel ne sert à rien.

Un 429 et son en-tête :

HTTP/2 429
content-type: application/json; charset=utf-8
retry-after: 1

{"error":"rate_limited","message":"Trop de requêtes : le palier Découverte permet 1 requête par seconde. Réessayez dans une seconde."}

Un point à New York :

{"error":"out_of_coverage","message":"Point hors de la zone couverte : l’API couvre l’Europe. Pour d’autres régions, contactez-nous (palier Sur mesure)."}

Les versions ci-dessous réessaient ce qui est passager, s’arrêtent sur le reste et traduisent chaque cas en message utile. Trois nouvelles tentatives au plus, attente plafonnée à 60 secondes : le script ne reste jamais bloqué.

itineraire-robuste.mjs

// itineraire-robuste.mjs : node itineraire-robuste.mjs (Node.js 18 ou plus récent)
const API_URL = 'https://api.onvaou.app/v1/routes';
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

class RouteError extends Error {
  constructor(status, code, message) {
    super(message || `HTTP ${status}`);
    this.status = status;
    this.code = code;
  }
}

async function computeRoute(body, { maxRetries = 3 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(API_URL, {
      method: 'POST',
      headers: { 'x-api-key': process.env.OVO_API_KEY ?? '', 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(30_000),
    });
    const data = await res.json().catch(() => ({}));
    if (res.ok) return data;

    // Passagers : 429 rate_limited (débit par seconde) et 503 (capacité de calcul).
    // Pas 429 quota_exceeded : le quota du mois ne se libère pas en quelques secondes.
    const transient = (res.status === 429 && data.error !== 'quota_exceeded') || res.status === 503;
    if (transient && attempt < maxRetries) {
      const retryAfter = Number(res.headers.get('retry-after'));
      await sleep(Math.min(retryAfter > 0 ? retryAfter : 2 ** attempt, 60));
      continue;
    }
    throw new RouteError(res.status, data.error, data.message);
  }
}

try {
  const data = await computeRoute({
    origin: [2.3522, 48.8566],
    destination: [2.4392, 48.8474],
    mode: 'bike',
  });
  console.log(`${data.routes.length} itinéraire(s), ${data.usage.requests} requête(s) ce mois-ci sur ${data.usage.quota}`);
} catch (err) {
  if (!(err instanceof RouteError)) throw err;
  switch (err.code) {
    case 'out_of_coverage':
      console.error('Un des points est hors d’Europe : demandez un autre point.');
      break;
    case 'no_route':
      console.error('Aucun itinéraire pour ce véhicule : rapprochez les points d’une rue ou d’un chemin praticable.');
      break;
    case 'quota_exceeded':
      console.error('Quota du mois atteint : changez de palier sur https://console.onvaou.app.');
      break;
    default:
      console.error(`Erreur ${err.status} ${err.code ?? ''} : ${err.message}`);
  }
  process.exitCode = 1;
}

itineraire_robuste.py

# itineraire_robuste.py : pip install requests, puis python3 itineraire_robuste.py
import os
import sys
import time

import requests

API_URL = "https://api.onvaou.app/v1/routes"


class RouteError(Exception):
    def __init__(self, status, code, message):
        super().__init__(message or f"HTTP {status}")
        self.status = status
        self.code = code


def compute_route(body, max_retries=3):
    for attempt in range(max_retries + 1):
        resp = requests.post(
            API_URL,
            headers={"x-api-key": os.environ.get("OVO_API_KEY", "")},
            json=body,
            timeout=30,
        )
        try:
            data = resp.json()
        except ValueError:
            data = {}
        if resp.ok:
            return data

        # Passagers : 429 rate_limited (débit par seconde) et 503 (capacité de calcul).
        # Pas 429 quota_exceeded : le quota du mois ne se libère pas en quelques secondes.
        transient = (resp.status_code == 429 and data.get("error") != "quota_exceeded") or resp.status_code == 503
        if transient and attempt < max_retries:
            retry_after = resp.headers.get("Retry-After", "")
            time.sleep(min(int(retry_after) if retry_after.isdigit() else 2 ** attempt, 60))
            continue
        raise RouteError(resp.status_code, data.get("error"), data.get("message"))


MESSAGES = {
    "out_of_coverage": "Un des points est hors d’Europe : demandez un autre point.",
    "no_route": "Aucun itinéraire pour ce véhicule : rapprochez les points d’une rue ou d’un chemin praticable.",
    "quota_exceeded": "Quota du mois atteint : changez de palier sur https://console.onvaou.app.",
}

try:
    data = compute_route({"origin": [2.3522, 48.8566], "destination": [2.4392, 48.8474], "mode": "bike"})
    usage = data["usage"]
    print(f"{len(data['routes'])} itinéraire(s), {usage['requests']} requête(s) ce mois-ci sur {usage['quota']}")
except RouteError as err:
    sys.exit(MESSAGES.get(err.code, f"Erreur {err.status} {err.code or ''} : {err}"))

Pour éviter le 429 en amont, espacez vos appels selon le débit de votre palier : 1 requête par seconde en Découverte, 2 en Solo, jusqu’à 25 en Business.

10. Bonus : un assistant d’IA qui calcule l’itinéraire

L’API existe aussi en serveur MCP (Model Context Protocol) : un assistant comme Claude appelle lui-même l’outil compute_route et vous répond en langage courant. Le serveur est publié sur npm (onvaou-itineraires-mcp) et dans le registre MCP officiel (app.onvaou/itineraires). Il ne fait que lire, et chaque calcul compte comme une requête.

Claude Desktop

Il faut Node.js 18 ou plus récent, pour npx. Ouvrez le fichier de configuration de Claude Desktop (accessible depuis les réglages de l’application, section Developer) :

Ajoutez-y le serveur, avec votre clé :

claude_desktop_config.json

{
  "mcpServers": {
    "onvaou-itineraires": {
      "command": "npx",
      "args": ["-y", "onvaou-itineraires-mcp"],
      "env": { "OVO_API_KEY": "ovo_live_..." }
    }
  }
}

Claude Desktop ne lit pas les variables d’environnement de votre terminal : la clé se place dans le bloc env, et ce fichier reste sur votre machine. Redémarrez Claude Desktop, puis demandez par exemple :

Calcule l’itinéraire vélo le plus sûr entre l’Hôtel de Ville de Paris (2.3522, 48.8566) et Vincennes (2.4392, 48.8474), et dis-moi quelle part du trajet est sur piste cyclable.

L’assistant appelle compute_route et répond avec la distance, la durée et les indicateurs du trajet : pour cet exemple, le 21 septembre 2026, 7,2 km, 25 minutes et 77 % de pistes cyclables. L’outil travaille sur des coordonnées : donnez-les, ou laissez l’assistant les déduire des adresses.

Claude Code

Une ligne suffit, et la clé vient de votre variable d’environnement :

claude mcp add onvaou-itineraires -e OVO_API_KEY="$OVO_API_KEY" -- npx -y onvaou-itineraires-mcp

Pour aller plus loin