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 :
- Détenir la clé API, qui ne quitte jamais votre infrastructure.
- Authentifier votre utilisateur — c'est votre jeton applicatif qui circule, pas la clé du fournisseur.
- Mutualiser le cache entre tous vos utilisateurs : deux personnes qui interrogent la même plaque ne déclenchent qu'un appel facturé.
- 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 :
| Format | Motif | Exemple |
|---|---|---|
| SIV (depuis 2009) | 2 lettres, 3 chiffres, 2 lettres | AA-123-BB |
| FNI (avant 2009) | 1 à 4 chiffres, 2 à 3 lettres, 2 chiffres | 123 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.
| Niveau | Emplacement | Durée | Rôle |
|---|---|---|---|
| Mémoire | Process de l'app | Session | Évite les doubles appels d'un même écran |
| Disque | AsyncStorage, SharedPreferences, Room, Core Data | 24 h à 30 j | Survit au redémarrage, permet le mode hors-ligne |
| Serveur | Redis ou équivalent sur votre proxy | 24 h | Mutualisé 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 :
| Code | Signification | Réaction côté mobile |
|---|---|---|
| 400 | Format de plaque incorrect | Message de saisie, pas de retry |
| 401 | Clé API manquante ou invalide | Alerte côté serveur, message générique à l'utilisateur |
| 404 | Plaque introuvable | Message clair, proposer une saisie manuelle en repli |
| 429 | Quota dépassé | Retry avec backoff exponentiel, plafonné |
| 500 | Erreur interne | Un 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 ?
Peut-on appeler l'API directement depuis une application iOS ou Android ?
Comment fonctionne le mode hors-ligne avec une API plaque d'immatriculation ?
Quel format de plaque faut-il valider côté mobile ?
Que faire quand l'API renvoie un code 429 ?

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.
Articles similaires
Restez informé de nos actualités
Recevez les derniers articles et mises à jour directement par email.


