Guides / Lovable

Utiliser Normi dans une app Lovable

Ajoutez des estimations, l'historique des ventes DVF et les prix au m² de toute la France à une application générée avec Lovable, en une Edge Function Supabase et un prompt.

Ce que vous pouvez construire

  • Estimateur en ligne : un formulaire adresse + surface qui renvoie une fourchette de prix.
  • Outil de prospection : toutes les ventes récentes d'un secteur pour cibler les propriétaires.
  • Carte des ventes : les transactions DVF géolocalisées autour d'un point.
  • Tableau de bord de marché : prix au m², volumes et tendances par commune.

Toutes ces données passent par l'API REST Normi. Ce guide utilise /v1/estimate et /v1/transactions.

Prérequis

  1. Une clé API Normi : créez-la depuis le tableau de bord (500 crédits offerts).
  2. Un projet Lovable connecté à Supabase (onglet Supabase de Lovable).

1. Stocker la clé en secret

Dans Supabase, ajoutez un secret d'Edge Function nommé NORMI_API_KEY :

NORMI_API_KEY=normi_votre_token
Jamais dans le code du front
Une clé collée dans un composant React est lisible par tous les visiteurs et peut vider vos crédits. L'API refuse d'ailleurs les appels navigateur depuis des domaines non autorisés.

2. Créer l'Edge Function

La fonction relaie les appels vers Normi, ajoute la clé et gère les limites de débit :

// supabase/functions/normi/index.ts
const NORMI = "https://mcp.normi.fr/v1";
const KEY = Deno.env.get("NORMI_API_KEY")!;
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, content-type, apikey, x-client-info",
};

async function normi(path: string, params: Record<string, string | number>) {
  const qs = new URLSearchParams(
    Object.entries(params).map(([k, v]) => [k, String(v)]),
  );
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(`${NORMI}${path}?${qs}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    // 429 : attendre le délai indiqué puis réessayer
    if (res.status === 429) {
      const wait = Number(res.headers.get("retry-after") ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    const body = await res.json();
    if (!res.ok) throw new Error(`${body.error?.code}: ${body.error?.message}`);
    return body;
  }
  throw new Error("rate_limited: trop de tentatives");
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
  try {
    const { action, ...params } = await req.json();
    const data =
      action === "estimate"
        ? await normi("/estimate", params)
        : await normi("/transactions", params);
    return Response.json(data, { headers: CORS });
  } catch (e) {
    return Response.json({ error: String(e) }, { status: 400, headers: CORS });
  }
});

Côté interface, Lovable l'appelle avec le client Supabase :

import { supabase } from "@/integrations/supabase/client";

const { data } = await supabase.functions.invoke("normi", {
  body: {
    action: "estimate",
    latitude: 43.6085,
    longitude: 3.8797,
    surface: 65,
    pieces: 3,
    type_local: "Appartement",
  },
});
// data.estimate.price_range.mid -> estimation centrale en euros

3. Le prompt à coller dans Lovable

Lovable devine parfois les noms de paramètres. Ce prompt lui donne les bons :

Ajoute une estimation immobilière basée sur l'API Normi (données DVF).

- Crée une Edge Function Supabase "normi" qui appelle https://mcp.normi.fr/v1
  avec l'en-tête Authorization: Bearer <secret NORMI_API_KEY>.
  Ne jamais appeler l'API Normi depuis le navigateur.
- Estimation : GET /v1/estimate avec latitude, longitude, surface (obligatoires),
  pieces et type_local ("Appartement" ou "Maison").
- Ventes : GET /v1/transactions avec code_postal (5 chiffres), type_local,
  date_debut (YYYY-MM-DD), limit (200 maximum) et offset.
  Paginer avec next_cursor jusqu'à ce qu'il vaille null.
- N'invente aucun paramètre : "city" ou "postal_code" n'existent pas,
  l'API renvoie une erreur 400 sur tout paramètre inconnu.
- Sur une réponse 429, attendre le nombre de secondes de l'en-tête retry-after.
- Sur une réponse 402, afficher "Crédits épuisés" au lieu d'un écran vide.
- Affiche price_range.low / mid / high et price_per_m2.median du résultat.

Exemple : un T3 à Montpellier

Appartement de 65 m², 3 pièces, près de la place de la Comédie. Normi compare avec les ventes d'appartements à moins de 500 m sur les 18 derniers mois de données DVF : médiane autour de 3 760 €/m², soit environ 244 500 €. Réponse abrégée :

{
  "estimate": {
    "price_range": { "mid": 244500 },
    "price_per_m2": { "median": 3762, "used_surface": 65 },
    "confidence": "high"
  },
  "input": {
    "latitude": 43.6085,
    "longitude": 3.8797,
    "surface": 65,
    "type_local": "Appartement"
  },
  "_credits": { "used": 10, "remaining": 490 }
}

La réponse complète inclut la fourchette basse et haute, le nombre de ventes comparables et les facteurs de confiance : référence /v1/estimate.

Récupérer toutes les ventes d'un secteur

/v1/transactions renvoie 200 ventes au plus par appel. Suivez next_cursor pour parcourir le reste :

// Toutes les ventes d'appartements à Montpellier 34000 depuis juillet 2025
const sales = [];
let offset = 0;
while (offset !== null) {
  const page = await normi("/transactions", {
    code_postal: "34000",
    type_local: "Appartement",
    date_debut: "2025-07-01",
    limit: 200,          // maximum autorisé
    offset,
  });
  sales.push(...page.transactions);
  offset = page.next_cursor; // null quand c'est la dernière page
}
Restreignez la période
Chaque page coûte 5 crédits. Un filtre date_debut sur 12 mois plutôt que 5 ans divise la facture et le temps de chargement.

Pièges fréquents

ErreurCauseSolution
missing_location (400)Aucun filtre de lieu reconnu, souvent city ou postal_code au lieu de code_postal.Utilisez code_postal, commune, code_departement, ou latitude + longitude.
invalid_params (400)limit au-delà de 200, paramètre inconnu, latitude manquante.limit ≤ 200 et paginez avec offset. Seuls les paramètres documentés sont acceptés.
rate_limited (429)Trop d'appels par minute, typique d'une boucle de pagination sans pause.Respectez l'en-tête retry-after. La fonction ci-dessus le fait déjà.
insufficient_credits (402)Solde à zéro.Rechargez depuis le tableau de bord. Les crédits gratuits reviennent à 500 le 1er du mois.
at_capacity (503)Serveur saturé pendant quelques secondes. Rien n'est débité.Réessayez après une courte pause.

Coût en crédits

  • /v1/estimate : 10 crédits par estimation.
  • /v1/transactions : 5 crédits par page, quelle que soit sa taille. 1 000 ventes = 5 pages = 25 crédits.
  • Les appels en erreur ne sont pas débités.

Fonctionnement des crédits · Tarifs

Questions fréquentes

Faut-il un plan payant Normi pour tester ?

Non. Chaque compte reçoit 500 crédits gratuits, soit 50 estimations ou 100 pages de 200 ventes. Ils sont rechargés chaque mois.

Puis-je appeler l'API directement depuis le front Lovable ?

Non. Votre clé serait visible par tous les visiteurs et l'API n'accepte les appels navigateur que depuis une liste d'origines autorisées. Passez par une Edge Function Supabase.

Les données sont-elles à jour ?

Les ventes viennent des fichiers DVF publiés par la DGFiP, mis à jour une fois par an. Chaque réponse indique la date de la dernière vente disponible.