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 de ReportInputSchema.

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 sur https://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 .jpg qui n’en est pas un est refusé en 415.
  • Taille maximale : 10 Mo.
  • Réponse 201 : { "fileKey": "pending/<uuid>.jpg", "expiresAt": "...", "sizeBytes": 0, "mime": "..." }.
  • La fileKey est 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 renvoie 400 invalid_idempotency_key.
  • Premier appel : 201 avec le signalement créé. Rejeu avec la même clé : 200 avec 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

  1. 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.
  2. 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/recent est lisible.