Tutoriel

Intégrer une API plaque d'immatriculation dans une application mobile

Api Plaque ImmatriculationAugust 14, 202614 min de lecture
#mobile#react native#flutter#swift#kotlin#api plaque immatriculation
Intégrer une API plaque d'immatriculation dans une application mobile

Intégrer une API plaque d'immatriculation dans une application mobile pose un problème que les intégrations web n'ont pas : votre code s'exécute sur un appareil que vous ne contrôlez pas. Une clé API placée dans un binaire iOS ou Android est extractible en quelques minutes — c'est l'erreur la plus fréquente et la plus coûteuse de ce type de projet. Ce guide traite exclusivement du cas mobile : l'architecture à adopter, puis le code réel pour React Native, Flutter, Swift et Kotlin, avec le cache local, la gestion des erreurs réseau et la validation de la saisie de plaque.

Pour une intégration web ou backend classique (JavaScript, Python, PHP), reportez-vous au guide d'intégration développeur. Cet article suppose ce socle acquis et se concentre sur les contraintes propres au mobile.

L'architecture : jamais d'appel direct depuis l'appareil

La règle est absolue : la clé API ne doit jamais se trouver dans votre application mobile. Ni en dur dans le code, ni dans un fichier de configuration, ni dans les variables de build, ni dans le trousseau après un premier téléchargement.

Pourquoi c'est sans appel : un binaire mobile est distribué à vos utilisateurs. Il se décompresse, ses chaînes de caractères se lisent, son trafic réseau s'inspecte avec un proxy. L'obfuscation ralentit un attaquant de quelques minutes, pas davantage. Une clé extraite, c'est votre quota consommé par un tiers, votre facture qui explose, et votre compte suspendu pour usage anormal.

L'architecture correcte comporte trois étages :

[App mobile]  ──►  [Votre backend]  ──►  [API plaque immatriculation]
   plaque           + clé API              données véhicule
   + jeton user     + cache
                    + rate limit

Votre backend joue quatre rôles, tous nécessaires :

  1. Détenir la clé API, qui ne quitte jamais votre infrastructure.
  2. Authentifier votre utilisateur — c'est votre jeton applicatif qui circule, pas la clé du fournisseur.
  3. Mutualiser le cache entre tous vos utilisateurs : deux personnes qui interrogent la même plaque ne déclenchent qu'un appel facturé.
  4. Appliquer votre propre limitation de débit, pour qu'un client compromis ne vide pas votre quota.

Voici ce proxy en Node.js / Express, avec cache mémoire et limitation simple :

import express from "express";
 
const app = express();
const cache = new Map(); // en production : Redis
const TTL_MS = 24 * 60 * 60 * 1000;
 
const PLATE_RE = /^[A-Z]{2}[0-9]{3}[A-Z]{2}$|^[0-9]{1,4}[A-Z]{2,3}[0-9]{2}$/;
 
app.get("/api/vehicule/:plaque", async (req, res) => {
  const plaque = req.params.plaque.toUpperCase().replace(/[\s-]/g, "");
 
  if (!PLATE_RE.test(plaque)) {
    return res.status(400).json({ error: "Format de plaque invalide" });
  }
 
  const hit = cache.get(plaque);
  if (hit && Date.now() - hit.at < TTL_MS) {
    return res.json(hit.data);
  }
 
  try {
    const upstream = await fetch(
      `https://api-de-plaque-d-immatriculation-france.p.rapidapi.com/?plaque=${plaque}`,
      {
        headers: {
          "x-rapidapi-key": process.env.RAPIDAPI_KEY,
          "x-rapidapi-host":
            "api-de-plaque-d-immatriculation-france.p.rapidapi.com",
        },
        signal: AbortSignal.timeout(8000),
      },
    );
 
    if (!upstream.ok) {
      return res.status(upstream.status).json({ error: "Requête amont échouée" });
    }
 
    const data = await upstream.json();
    cache.set(plaque, { at: Date.now(), data });
    res.json(data);
  } catch {
    res.status(504).json({ error: "Délai dépassé" });
  }
});
 
app.listen(3000);

Si vous n'avez pas de backend, une fonction serverless (Vercel, Netlify, Cloudflare Workers, AWS Lambda) suffit : quelques dizaines de lignes et un secret d'environnement. Ce n'est pas une raison d'embarquer la clé dans l'application.

Valider le format de plaque avant l'appel

Chaque appel compte dans votre quota. Une plaque manifestement invalide ne doit jamais partir sur le réseau. Deux formats à couvrir en France :

FormatMotifExemple
SIV (depuis 2009)2 lettres, 3 chiffres, 2 lettresAA-123-BB
FNI (avant 2009)1 à 4 chiffres, 2 à 3 lettres, 2 chiffres123 ABC 45

Normalisez toujours avant de valider : majuscules, suppression des espaces et des tirets. Le détail des règles de composition est dans comment lire une plaque d'immatriculation française.

export function normaliserPlaque(saisie) {
  return saisie.toUpperCase().replace(/[\s-]/g, "");
}
 
const SIV = /^[A-Z]{2}[0-9]{3}[A-Z]{2}$/;
const FNI = /^[0-9]{1,4}[A-Z]{2,3}[0-9]{2}$/;
 
export function plaqueValide(saisie) {
  const p = normaliserPlaque(saisie);
  return SIV.test(p) || FNI.test(p);
}

Côté ergonomie mobile, trois détails changent le taux de complétion : forcez le clavier en majuscules, insérez les tirets automatiquement pendant la frappe, et n'affichez l'erreur qu'à la perte du focus — pas à chaque caractère.

React Native

Appel du proxy, avec état de chargement, annulation et cache local via AsyncStorage :

import { useCallback, useState } from "react";
import { View, TextInput, Button, Text } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
const TTL_MS = 24 * 60 * 60 * 1000;
const API = "https://votre-backend.example.com";
 
async function lireCache(plaque) {
  const brut = await AsyncStorage.getItem(`veh:${plaque}`);
  if (!brut) return null;
  const { at, data } = JSON.parse(brut);
  return Date.now() - at < TTL_MS ? data : null;
}
 
export default function RecherchePlaque() {
  const [plaque, setPlaque] = useState("");
  const [vehicule, setVehicule] = useState(null);
  const [erreur, setErreur] = useState(null);
  const [chargement, setChargement] = useState(false);
 
  const rechercher = useCallback(async () => {
    const p = plaque.toUpperCase().replace(/[\s-]/g, "");
    if (!/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/.test(p)) {
      setErreur("Format attendu : AA-123-BB");
      return;
    }
 
    setErreur(null);
    setChargement(true);
 
    try {
      const enCache = await lireCache(p);
      if (enCache) {
        setVehicule(enCache);
        return;
      }
 
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), 10000);
      const res = await fetch(`${API}/api/vehicule/${p}`, { signal: ctrl.signal });
      clearTimeout(timer);
 
      if (res.status === 404) throw new Error("Véhicule introuvable");
      if (res.status === 429) throw new Error("Trop de requêtes, réessayez plus tard");
      if (!res.ok) throw new Error("Service indisponible");
 
      const json = await res.json();
      await AsyncStorage.setItem(
        `veh:${p}`,
        JSON.stringify({ at: Date.now(), data: json.data }),
      );
      setVehicule(json.data);
    } catch (e) {
      setErreur(e.message);
    } finally {
      setChargement(false);
    }
  }, [plaque]);
 
  return (
    <View>
      <TextInput
        value={plaque}
        onChangeText={setPlaque}
        autoCapitalize="characters"
        autoCorrect={false}
        placeholder="AA-123-BB"
      />
      <Button title="Rechercher" onPress={rechercher} disabled={chargement} />
      {erreur && <Text>{erreur}</Text>}
      {vehicule && (
        <Text>
          {vehicule.marque} {vehicule.modele}{vehicule.energie}
        </Text>
      )}
    </View>
  );
}

Flutter / Dart

Même logique, avec http et shared_preferences :

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
 
class VehiculeService {
  static const _api = 'https://votre-backend.example.com';
  static const _ttl = Duration(hours: 24);
  static final _siv = RegExp(r'^[A-Z]{2}[0-9]{3}[A-Z]{2}$');
 
  static String normaliser(String saisie) =>
      saisie.toUpperCase().replaceAll(RegExp(r'[\s-]'), '');
 
  Future<Map<String, dynamic>> rechercher(String saisie) async {
    final plaque = normaliser(saisie);
    if (!_siv.hasMatch(plaque)) {
      throw FormatException('Format attendu : AA-123-BB');
    }
 
    final prefs = await SharedPreferences.getInstance();
    final cache = prefs.getString('veh:$plaque');
    if (cache != null) {
      final entree = jsonDecode(cache) as Map<String, dynamic>;
      final age = DateTime.now().millisecondsSinceEpoch - (entree['at'] as int);
      if (age < _ttl.inMilliseconds) {
        return entree['data'] as Map<String, dynamic>;
      }
    }
 
    final reponse = await http
        .get(Uri.parse('$_api/api/vehicule/$plaque'))
        .timeout(const Duration(seconds: 10));
 
    switch (reponse.statusCode) {
      case 200:
        break;
      case 404:
        throw Exception('Véhicule introuvable');
      case 429:
        throw Exception('Quota dépassé, réessayez plus tard');
      default:
        throw Exception('Service indisponible (${reponse.statusCode})');
    }
 
    final data = (jsonDecode(reponse.body) as Map<String, dynamic>)['data']
        as Map<String, dynamic>;
 
    await prefs.setString(
      'veh:$plaque',
      jsonEncode({'at': DateTime.now().millisecondsSinceEpoch, 'data': data}),
    );
 
    return data;
  }
}

Swift (iOS)

Avec async/await, URLSession et un modèle Codable qui ne décode que les champs utiles :

import Foundation
 
struct Vehicule: Codable {
    let marque: String
    let modele: String
    let version: String?
    let energie: String?
    let puissanceFiscale: String?
 
    enum CodingKeys: String, CodingKey {
        case marque, modele, version, energie
        case puissanceFiscale = "puissance_fiscale"
    }
}
 
struct ReponseAPI: Codable {
    let error: Bool
    let code: Int
    let data: Vehicule?
}
 
enum ErreurVehicule: Error {
    case formatInvalide, introuvable, quotaDepasse, indisponible
}
 
final class VehiculeService {
    private let base = URL(string: "https://votre-backend.example.com")!
    private let cache = NSCache<NSString, NSData>()
 
    private static let siv = try! NSRegularExpression(
        pattern: "^[A-Z]{2}[0-9]{3}[A-Z]{2}$"
    )
 
    static func normaliser(_ saisie: String) -> String {
        saisie.uppercased()
            .replacingOccurrences(of: " ", with: "")
            .replacingOccurrences(of: "-", with: "")
    }
 
    func rechercher(_ saisie: String) async throws -> Vehicule {
        let plaque = Self.normaliser(saisie)
        let plage = NSRange(plaque.startIndex..., in: plaque)
        guard Self.siv.firstMatch(in: plaque, range: plage) != nil else {
            throw ErreurVehicule.formatInvalide
        }
 
        if let brut = cache.object(forKey: plaque as NSString) {
            return try JSONDecoder().decode(Vehicule.self, from: brut as Data)
        }
 
        var requete = URLRequest(url: base.appendingPathComponent("api/vehicule/\(plaque)"))
        requete.timeoutInterval = 10
 
        let (donnees, reponse) = try await URLSession.shared.data(for: requete)
        guard let http = reponse as? HTTPURLResponse else {
            throw ErreurVehicule.indisponible
        }
 
        switch http.statusCode {
        case 200: break
        case 404: throw ErreurVehicule.introuvable
        case 429: throw ErreurVehicule.quotaDepasse
        default: throw ErreurVehicule.indisponible
        }
 
        let enveloppe = try JSONDecoder().decode(ReponseAPI.self, from: donnees)
        guard let vehicule = enveloppe.data else { throw ErreurVehicule.introuvable }
 
        if let encode = try? JSONEncoder().encode(vehicule) {
            cache.setObject(encode as NSData, forKey: plaque as NSString)
        }
        return vehicule
    }
}

Le NSCache ci-dessus ne survit pas au redémarrage de l'application. Pour un cache persistant, écrivez le JSON dans le répertoire Caches ou utilisez SwiftData / Core Data selon votre pile.

Kotlin (Android)

Avec Retrofit, coroutines et un intercepteur OkHttp de cache :

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
 
data class Vehicule(
    val marque: String,
    val modele: String,
    val version: String?,
    val energie: String?,
)
 
data class ReponseApi(val error: Boolean, val code: Int, val data: Vehicule?)
 
interface VehiculeApi {
    @GET("api/vehicule/{plaque}")
    suspend fun rechercher(@Path("plaque") plaque: String): ReponseApi
}
 
sealed class ResultatVehicule {
    data class Succes(val vehicule: Vehicule) : ResultatVehicule()
    data class Echec(val message: String) : ResultatVehicule()
}
 
class VehiculeRepository(private val api: VehiculeApi) {
 
    private val siv = Regex("^[A-Z]{2}[0-9]{3}[A-Z]{2}$")
    private val cache = mutableMapOf<String, Pair<Long, Vehicule>>()
    private val ttlMs = 24 * 60 * 60 * 1000L
 
    private fun normaliser(saisie: String) =
        saisie.uppercase().replace(Regex("[\\s-]"), "")
 
    suspend fun rechercher(saisie: String): ResultatVehicule = withContext(Dispatchers.IO) {
        val plaque = normaliser(saisie)
        if (!siv.matches(plaque)) {
            return@withContext ResultatVehicule.Echec("Format attendu : AA-123-BB")
        }
 
        cache[plaque]?.let { (horodatage, vehicule) ->
            if (System.currentTimeMillis() - horodatage < ttlMs) {
                return@withContext ResultatVehicule.Succes(vehicule)
            }
        }
 
        try {
            val reponse = api.rechercher(plaque)
            val vehicule = reponse.data
                ?: return@withContext ResultatVehicule.Echec("Véhicule introuvable")
            cache[plaque] = System.currentTimeMillis() to vehicule
            ResultatVehicule.Succes(vehicule)
        } catch (e: HttpException) {
            ResultatVehicule.Echec(
                when (e.code()) {
                    404 -> "Véhicule introuvable"
                    429 -> "Quota dépassé, réessayez plus tard"
                    else -> "Service indisponible"
                }
            )
        } catch (e: Exception) {
            ResultatVehicule.Echec("Erreur réseau")
        }
    }
}

En production, remplacez la Map en mémoire par Room ou DataStore pour que le cache survive au redémarrage.

Cache local et mode hors-ligne

Le mobile ajoute une contrainte que le web n'a pas : la connexion peut disparaître au milieu d'un parcours. Trois niveaux de cache répondent à des besoins différents.

NiveauEmplacementDuréeRôle
MémoireProcess de l'appSessionÉvite les doubles appels d'un même écran
DisqueAsyncStorage, SharedPreferences, Room, Core Data24 h à 30 jSurvit au redémarrage, permet le mode hors-ligne
ServeurRedis ou équivalent sur votre proxy24 hMutualisé entre tous les utilisateurs, c'est lui qui réduit la facture

Les caractéristiques techniques d'un véhicule ne changent pratiquement jamais : une durée de 24 heures est prudente, 7 à 30 jours restent acceptables pour un usage de consultation. Les champs administratifs — dates de carte grise notamment — méritent une durée plus courte.

En mode hors-ligne, affichez la donnée mise en cache avec une mention explicite de sa date plutôt qu'un écran vide : c'est presque toujours l'information dont l'utilisateur a besoin.

Erreurs et rate limiting

Les codes renvoyés par l'API et la réaction correcte pour chacun :

CodeSignificationRéaction côté mobile
400Format de plaque incorrectMessage de saisie, pas de retry
401Clé API manquante ou invalideAlerte côté serveur, message générique à l'utilisateur
404Plaque introuvableMessage clair, proposer une saisie manuelle en repli
429Quota dépasséRetry avec backoff exponentiel, plafonné
500Erreur interneUn retry, puis message d'indisponibilité

Deux règles simples : ne retentez jamais un 400 ou un 404 — la réponse ne changera pas, et vous consommez du quota pour rien. Et faites porter le backoff par votre proxy plutôt que par l'application, pour que 10 000 appareils ne réessaient pas simultanément.

Prévoyez toujours un chemin de repli manuel. Si l'API est indisponible ou la plaque introuvable, l'utilisateur doit pouvoir saisir sa marque et son modèle à la main plutôt que de se retrouver bloqué dans le parcours.

Checklist avant publication sur les stores

  • Aucune clé API dans le binaire, les fichiers de configuration ou les variables de build.
  • Tous les appels passent par votre backend, en HTTPS.
  • Validation du format de plaque avant l'appel réseau.
  • Timeout explicite sur chaque requête (8 à 10 secondes).
  • Cache disque avec durée de vie définie.
  • Chaque code d'erreur produit un message utilisateur distinct.
  • Backoff sur 429 et 5xx uniquement.
  • Chemin de repli manuel en cas d'échec.
  • Mention dans votre politique de confidentialité si vous stockez les plaques saisies.

Ce dernier point n'est pas cosmétique : une plaque associée à un compte utilisateur devient une donnée personnelle dans votre système, même si l'API ne vous renvoie aucune information sur le titulaire. Le cadre est détaillé dans API gouv / SIV : comment ça marche.

Conclusion

L'intégration mobile d'une API plaque d'immatriculation tient en une décision d'architecture — le proxy backend — et en quelques dizaines de lignes par plateforme. Le reste, cache, validation et gestion d'erreurs, relève de l'artisanat habituel des applications mobiles, mais c'est lui qui distingue une intégration qui tient en production d'une démonstration. Pour la référence complète des endpoints et des champs, consultez la documentation technique de lecture de plaque ; pour la vue d'ensemble, le guide complet 2026.

Questions fréquentes

Comment intégrer une API plaque d'immatriculation dans une application mobile ?
En cinq étapes : créez un proxy sur votre backend qui détient la clé API, validez et normalisez la plaque côté application avant tout appel réseau, appelez votre proxy avec un timeout explicite (fetch en React Native, http en Flutter, URLSession en Swift, Retrofit en Kotlin), mettez la réponse en cache localement pour au moins 24 heures, et traitez distinctement les codes 400, 401, 404, 429 et 5xx. Le point non négociable est le proxy : la clé API ne doit jamais se trouver dans le binaire distribué.
Peut-on appeler l'API directement depuis une application iOS ou Android ?
Techniquement oui, mais il ne faut jamais le faire. Un binaire mobile est distribué à vos utilisateurs : ses chaînes de caractères se lisent et son trafic s'inspecte avec un proxy. Une clé API extraite signifie votre quota consommé par un tiers, une facture qui explose et un compte suspendu pour usage anormal. L'obfuscation ne fait que ralentir un attaquant de quelques minutes.
Comment fonctionne le mode hors-ligne avec une API plaque d'immatriculation ?
Les caractéristiques techniques d'un véhicule ne changent pratiquement jamais, ce qui rend le cache local très efficace. Stockez chaque réponse sur disque (AsyncStorage, SharedPreferences, Room ou Core Data) avec un horodatage, et servez-la sans réseau pendant 24 heures à 30 jours selon votre usage. Affichez alors la date de la donnée mise en cache plutôt qu'un écran vide.
Quel format de plaque faut-il valider côté mobile ?
Deux formats français coexistent : le format SIV depuis 2009, soit deux lettres, trois chiffres et deux lettres (AA-123-BB), et l'ancien format FNI, soit un à quatre chiffres, deux à trois lettres et deux chiffres (123 ABC 45). Normalisez toujours la saisie en majuscules sans espaces ni tirets avant de valider, et n'affichez l'erreur qu'à la perte du focus pour ne pas gêner la frappe.
Que faire quand l'API renvoie un code 429 ?
Le code 429 signale un quota dépassé ou un débit trop élevé. Appliquez un retry avec backoff exponentiel plafonné, mais faites-le porter par votre proxy backend plutôt que par l'application : sinon des milliers d'appareils réessaieront simultanément et aggraveront la situation. Côté utilisateur, affichez un message temporaire et proposez une saisie manuelle en repli.
Partager cet article
Api Plaque Immatriculation

Api Plaque Immatriculation

Expert en identification de véhicules et passionné par les technologies API. Api Plaque Immatriculation partage son expertise sur l'intégration des services de données automobiles.

Restez informé de nos actualités

Recevez les derniers articles et mises à jour directement par email.

S'abonner