API publique : déposer un signalement depuis un service tiers
Source de vérité des contrats :
packages/shared/src/report.ts(schémas Zod). Ce document est tenu à la main : le mettre à jour à chaque changement deReportInputSchema.
POST /api/reports accepte le dépôt d’un signalement par un tiers (association, collectif
d’usagers, application partenaire). L’endpoint reste ouvert sans authentification, mais un tiers
récurrent demande une clé d’API : elle attribue ses signalements, lui donne son propre quota, et
lui permet de relayer l’adresse IP de l’usager.
- Production :
https://4115.fr. L’API est exposée sur le même host que le site, sous le chemin/api(ingress). Un signalement se dépose donc surhttps://4115.fr/api/reports. - Développement :
https://dev.4115.fr - Local :
http://localhost:8080
Ce qu’il faut savoir avant d’intégrer
| Point | Règle actuelle |
|---|---|
| Authentification | facultative : clé d’API dans l’en-tête X-Api-Key, délivrée par l’administration. |
| CORS | Access-Control-Allow-Origin limité à l’origine du site 4115.fr. |
| Appels navigateur | bloqués par le CORS. L’intégration doit être serveur à serveur. |
| Quota avec clé | 120 requêtes / minute / partenaire par défaut, ajustable par partenaire. |
| Quota sans clé | 30 requêtes / minute / IP sur /api/*, 5 / minute / IP sur /api/uploads. |
| Rattachement | un signalement déposé sans session n’est lié à aucun compte utilisateur. |
| Format | JSON uniquement, Content-Type: application/json. |
Sans clé, le quota est global par IP : un serveur tiers partage ses 30 req/min entre tous ses
appels, lectures comprises. Avec une clé valide, ce quota par IP ne s’applique plus, remplacé par
celui du partenaire. Dans les deux cas, prévoir une file d’attente et un retry sur 429 plutôt que
des rafales.
Authentification par clé d’API
La clé se présente dans un en-tête dédié, sur n’importe quelle route /api/* :
X-Api-Key: 4115_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Elle est créée dans l’onglet Partenaires de l’administration, et n’est affichée qu’une fois : 4115.fr n’en conserve qu’une empreinte SHA-256, personne ne peut la relire.
- Une clé inconnue, révoquée, ou rattachée à un partenaire désactivé renvoie
401 invalid_api_key. La requête n’est pas rejouée en anonyme : une clé qui a tourné se voit immédiatement. - Plusieurs clés peuvent vivre en parallèle pour un même partenaire, le temps d’une rotation : déployer la nouvelle, vérifier, puis demander la révocation de l’ancienne.
- Les signalements déposés avec la clé portent le partenaire dans leur champ
source, et restent attribués même après la fin de la relation.
Demander une clé, sa rotation ou sa révocation passe par le formulaire de contact.
Parcours d’intégration
1. Récupérer les lignes disponibles
curl https://4115.fr/api/lines
Chaque ligne expose un id (UUID) et un code (1 à 16 caractères : lettres accentuées, chiffres,
espace, apostrophe, point ou tiret, ex. C3, TàD 2429, T'bus 1).
Une ligne s’adresse par son id. Le code n’est unique qu’au sein d’un réseau : deux
réseaux peuvent exploiter un T3. Les chemins sont donc GET /api/lines/{id},
GET /api/lines/{id}/service, GET /api/lines/{id}/map, et un id mal formé renvoie
400 invalid_line_id. Un signalement référence de préférence lineId ; lineCode reste accepté et
n’est résolu que si le code désigne une seule ligne (sinon 422 line_code_ambiguous).
GET /api/lines ne renvoie que les lignes ouvertes aux signalements. Une ligne expose aussi
cityId / city (la ville du catalogue), networkId / network (le réseau, null si aucun),
active et hasMap (un plan de ligne est disponible sur GET /api/lines/{id}/map).
Une ligne désactivée disparaît de la liste mais reste lisible sur GET /api/lines/{id} avec
active: false : un tiers qui l’a en cache sait ainsi pourquoi son dépôt est refusé.
1 bis. Récupérer la desserte de la ligne (obligatoire)
curl https://4115.fr/api/lines/8f14e45f-ea0a-4c3b-9f2d-2b1c7a9d0e31/service
{
"directions": [{ "id": "…", "label": "Gare SNCF", "position": 0 }],
"stops": [
{
"id": "…",
"name": "Place de la Mairie",
"cityId": "…",
"city": "Montpellier",
"latitude": 43.608182,
"longitude": 3.879709,
"createdAt": "…",
"position": 0
}
]
}
latitude / longitude sont en degrés décimaux WGS84 et facultatifs : les deux valent null
tant que l’arrêt n’a pas été géopositionné en admin. Les arrêts sont triés par ordre de parcours
(position).
Les champs stop et direction d’un signalement doivent reprendre exactement un stops[].name
et un directions[].label de cette réponse : l’API refuse toute autre valeur (422 stop_not_served
/ 422 direction_not_served). C’est ce qui garantit que les statistiques agrègent des libellés
identiques plutôt que des variantes orthographiques. La desserte est administrée côté 4115.fr : si
un arrêt manque, demandez son ajout via le formulaire de contact.
2. (Facultatif) Envoyer une photo
curl -X POST https://4115.fr/api/uploads \
-F "file=@panneau.jpg;type=image/jpeg"
- Types acceptés :
image/jpeg,image/png,image/webp,image/heic. Le type déclaré est recoupé avec les octets du fichier ; un.jpgqui n’en est pas un est refusé en415. - Taille maximale : 10 Mo.
- Réponse
201:{ "fileKey": "pending/<uuid>.jpg", "expiresAt": "...", "sizeBytes": 0, "mime": "..." }. - La
fileKeyest valable 24 h et à usage unique : elle doit être rattachée à un signalement avant expiration, sinon elle est purgée.
3. Déposer le signalement
curl -X POST https://4115.fr/api/reports \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: 4115_live_votre_cle' \
-d '{
"identity": "anonymous",
"lineId": "8f14e45f-ea0a-4c3b-9f2d-2b1c7a9d0e31",
"stop": "Place de la Mairie",
"direction": "Gare SNCF",
"outcome": "passed",
"scheduledTime": "08:12",
"busTime": "08:41",
"note": "Signalement transmis via le formulaire de l'\''association."
}'
Réponse 201 :
{
"id": "9b1f...",
"createdAt": "2026-09-22T06:41:12.000Z",
"line": {
"id": "8f14e45f-ea0a-4c3b-9f2d-2b1c7a9d0e31",
"code": "C3",
"label": "...",
"city": "...",
"network": null,
"color": "#0055aa"
},
"delayMinutes": 29,
"severity": "major",
"stop": "Place de la Mairie",
"direction": "Gare SNCF",
"identity": "anonymous",
"verified": false,
"source": { "slug": "collectif-c3", "name": "Collectif ligne C3" }
}
source vaut null pour un signalement déposé sans clé, depuis le site comme depuis un tiers
anonyme. Il est également exposé par GET /api/reports/recent.
Rejouer un lot sans créer de doublons
Un partenaire qui rejoue un envoi (reprise après timeout, relance de file) ajoute un en-tête d’idempotence :
Idempotency-Key: lot-2026-10-01-0042
- La clé est opaque et choisie par le partenaire : 8 à 200 caractères parmi lettres, chiffres,
_,.,:et-. Une valeur hors de ce format renvoie400 invalid_idempotency_key. - Premier appel :
201avec le signalement créé. Rejeu avec la même clé :200avec le signalement d’origine, sans rien créer. Le code de statut est le seul moyen de distinguer les deux. - La clé n’est unique qu’au sein d’un partenaire : deux partenaires peuvent employer la même chaîne pour des signalements sans rapport.
- Un rejeu ne relit pas le corps de la requête : si celui-ci diffère, c’est le signalement d’origine qui est renvoyé. Une photo envoyée à nouveau n’est pas rattachée, elle expire d’elle-même.
- L’en-tête est ignoré sans clé d’API : la déduplication suppose un partenaire identifié.
- La fenêtre d’idempotence est de 30 jours : passé ce délai la clé est oubliée (le signalement, lui, reste), et la même clé créerait un nouveau signalement.
Corps de la requête
Champs communs
| Champ | Type | Requis | Contrainte |
|---|---|---|---|
identity |
enum | oui | signed | anonymous | technical |
lineId |
string (UUID) | oui* | identifiant de la ligne, tel que renvoyé par GET /api/lines |
lineCode |
string | oui* | repli : 1–16 caractères, refusé si le code désigne plusieurs lignes |
stop |
string | oui | doit correspondre à un stops[].name de la desserte |
direction |
string | oui | doit correspondre à un directions[].label de la desserte |
outcome |
enum | oui | passed | cancelled | left-stranded |
scheduledTime |
string | oui | HH:MM 24 h, horaire théorique |
busTime |
string | non | HH:MM, passage réel du bus (outcome: passed) |
arrivedTime |
string | non | HH:MM, arrivée de l’usager à l’arrêt |
leftTime |
string | non | HH:MM, départ de l’arrêt (outcome: left-stranded) |
note |
string | non | ≤ 2000 caractères |
photoFileKey |
string | null | non | fileKey obtenue à l’étape 2 |
* lineId ou lineCode, au moins l’un des deux. lineId est à préférer : il est stable et
sans ambiguïté.
Variantes d’identity
anonymous: aucune donnée personnelle enregistrée. C’est le choix par défaut pour un tiers qui ne transmet pas l’identité de l’usager.signed: trois champs deviennent obligatoires :firstName(≤ 80),lastName(≤ 80),contact(e-mail valide, ≤ 320). À n’utiliser que si l’usager final a consenti à la transmission de son identité (voir « Données personnelles » plus bas).technical: l’API enregistre une adresse IP et un User-Agent. Pour un appel depuis le site, ce sont ceux du navigateur ; pour un appel partenaire, ce sont ceux que le partenaire relaie (voir ci-dessous). Un partenaire qui n’est pas autorisé à relayer l’IP n’en fait rien enregistrer : l’IP de son serveur ne dirait rien de l’usager, elle n’est jamais stockée.
Avec anonymous et technical, les champs firstName / lastName / contact sont ignorés s’ils
sont envoyés.
Relayer l’adresse IP de l’usager
Un partenaire dont la fiche porte l’option « IP relayée » peut transmettre l’adresse de l’usager final, qui remplace alors celle de son propre serveur :
| En-tête | Contenu |
|---|---|
X-Client-Ip |
adresse IPv4 ou IPv6 de l’usager |
X-Client-User-Agent |
User-Agent de l’usager, tronqué à 512 caractères |
- Les deux en-têtes ne sont lus que pour
identity: "technical", et uniquement si l’option est activée sur la fiche du partenaire. Partout ailleurs ils sont ignorés. - Une valeur qui n’est pas une adresse IP valide est écartée sans faire échouer le dépôt : le signalement est enregistré sans IP.
- Ne pas passer par
X-Forwarded-For: cet en-tête est réservé à la chaîne de proxies de 4115.fr, un saut supplémentaire y fausserait l’IP de tous les autres appelants.
Activer cette option signifie que 4115.fr fait confiance au partenaire sur l’exactitude de ce qu’il déclare. Elle est désactivée par défaut.
Règle de cohérence horaire
Si busTime précède scheduledTime, la requête n’est acceptée que si l’écart s’explique par un
passage après minuit (moins de 180 minutes de retard une fois le jour suivant pris en compte). Sinon
: 400 sur le champ busTime.
Calcul du retard côté serveur
delayMinutes et severity sont calculés par l’API, jamais transmis par le client :
outcome |
delayMinutes |
severity |
|---|---|---|
cancelled |
null |
cancelled |
left-stranded |
null |
major |
passed sans busTime |
null |
minor |
passed avec busTime |
busTime - scheduledTime |
major si ≥ 5 min, sinon minor |
verified vaut toujours false à la création.
Erreurs
Toutes les erreurs ont la forme { "error": "...", "code"?: "...", "details"?: [...] }.
| Statut | error |
Cause |
|---|---|---|
| 400 | Données invalides |
validation du corps ; details[] liste { field, message } |
| 400 | invalid_idempotency_key |
Idempotency-Key hors format ; detail précise la contrainte |
| 401 | invalid_api_key |
clé inconnue, révoquée, ou partenaire désactivé |
| 413 | payload_too_large |
corps de requête trop volumineux |
| 413 | upload_too_large |
photo > 10 Mo |
| 415 | unsupported_mime |
type de photo non accepté ou octets incohérents |
| 400 | invalid_line_id |
{id} de l’URL n’est pas un UUID |
| 422 | line_not_found |
code renvoie le lineId ou lineCode inconnu |
| 422 | line_code_ambiguous |
candidates[] liste les { id, network } partageant ce code |
| 422 | line_disabled |
la ligne existe mais n’accepte plus de signalement |
| 422 | stop_not_served |
stop absent de la desserte de la ligne (field: "stop") |
| 422 | direction_not_served |
direction absente de la desserte (field: "direction") |
| 422 | invalid_photo_file_key |
photoFileKey expirée, déjà consommée ou introuvable |
| 429 | (aucun) | quota dépassé ; réessayer après la fenêtre d’une minute |
| 500 | Erreur interne du serveur |
incident côté API ; réessayer avec backoff |
| 503 | upload_disabled |
stockage objet non configuré sur cet environnement |
Les messages de details[].message sont rédigés en français et destinés à être affichés tels quels
à l’usager final.
Données personnelles
Un signalement signed transmet des données personnelles (nom, prénom, e-mail) à 4115.fr. Le tiers
est alors responsable de traitement pour la collecte, et doit :
- recueillir le consentement explicite de l’usager avant transmission ;
- l’informer de la politique de confidentialité de 4115.fr (https://4115.fr/confidentialite) et de la durée de conservation qui y est décrite ;
- ne pas transmettre de donnée personnelle dans
note, qui n’est pas prévue pour ça.
Une IP relayée est aussi une donnée personnelle. Elle n’est stockée que sur les signalements
technical, où elle suit la même règle de conservation que celle d’un usager du site :
anonymisation automatique au bout de 90 jours. Le partenaire doit informer l’usager de cette
transmission.
En cas de doute, utiliser anonymous : le signalement alimente les statistiques sans données
nominatives.
Limites connues
- CORS fermé. Toute intégration depuis le navigateur d’un tiers est impossible : l’intégration est serveur à serveur. C’est un choix, pas un oubli.
- Pas de webhook ni de relecture. Un partenaire ne peut ni consulter ni corriger les
signalements qu’il a déposés : seule la liste publique
GET /api/reports/recentest lisible.