{
  "openapi": "3.1.0",
  "info": {
    "title": "Qasqad — API",
    "version": "2.0",
    "description": "Synchronisation de flotte, vérification de clé et webhooks de Qasqad. Écrit à la main d'après webapp-fps-backend, branche feature/v2-backend (5d2b3e53), le 05/11/2026 ; à terme, généré par le back.",
    "contact": {
      "email": "support@qasqad.com"
    }
  },
  "servers": [
    {
      "url": "https://api.fpsnavigator.com"
    }
  ],
  "security": [
    {
      "apiKey": [],
      "timestamp": [],
      "signature": []
    }
  ],
  "paths": {
    "/api/auth/verify": {
      "post": {
        "operationId": "verify-key",
        "summary": "Vérifier la clé et la signature",
        "description": "Ne fait rien d'autre que contrôler l'authentification : c'est le premier appel à réussir. Envoyez {} comme corps, et signez ce {}.",
        "responses": {
          "201": {
            "description": "Clé, horodatage et signature valides. Corps : {\"status\":\"valid\"}.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "valid"
                    }
                  }
                },
                "example": {
                  "status": "valid"
                }
              }
            }
          },
          "401": {
            "description": "Authentification refusée ; error.message dit pourquoi quand il est fourni.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "tags": [
          "Authentification"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "description": "Un objet vide, {}, signé comme tout corps."
              },
              "example": {}
            }
          }
        }
      }
    },
    "/api/fleet": {
      "post": {
        "operationId": "sync-fleet",
        "summary": "Synchroniser les affectations de la flotte",
        "description": "Crée ou met à jour chaque affectation envoyée, identifiée par le triplet plaque + e-mail du conducteur + date de début. Rien n'est supprimé : une affectation absente de l'envoi reste en place. L'ancienne adresse POST /api/fleet/sync-fleet reste servie et fait la même chose.",
        "parameters": [
          {
            "name": "dryRun",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "true : la requête est authentifiée et validée, les lignes sont écrites dans une table de test. Votre flotte et vos FPS ne sont pas touchés."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SyncFleetRequest"
              },
              "example": {
                "vehicleAssignments": [
                  {
                    "licencePlate": "AB-123-CD",
                    "startDate": "2025-02-17T00:00:00+01:00",
                    "driverEmail": "conducteur@entreprise.fr",
                    "driverName": "Martin",
                    "driverFirstName": "Julie"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Lot accepté. Le corps de la réponse est vide."
          },
          "400": {
            "description": "Le corps ne respecte pas le schéma. Seule la première erreur est renvoyée, et rien n'est enregistré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Authentification refusée : en-tête manquant, horodatage hors fenêtre, clé invalide ou expirée, adresse IP non autorisée, signature fausse.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "499": {
            "description": "Un companySiret ne correspond à aucune société de votre organisation. Rien n'est enregistré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "tags": [
          "Flotte"
        ]
      }
    }
  },
  "webhooks": {
    "batch.processed": {
      "post": {
        "summary": "Lot traité",
        "description": "Tous les FPS du lot sont traités sans erreur : conducteurs identifiés, justificatifs produits.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchEvent"
              },
              "example": {
                "eventType": "batch.processed",
                "data": {
                  "uuid": "9f1c2e4a-6b0d-4c55-9a1e-2f7d3c8b5e10",
                  "status": "processed",
                  "type": "identify",
                  "isFromAntaiApi": true,
                  "organisationId": 42,
                  "createdAt": "2026-11-03T07:12:09.000Z",
                  "processedAt": "2026-11-03T07:14:52.000Z",
                  "parkingTickets": [
                    {
                      "uuid": "c03b7d18-2a4f-4e8b-b1c6-7d9e0f1a2b3c",
                      "paymentNotice": "12345678901234567890",
                      "paymentKey": "AB12",
                      "licencePlate": "AB-123-CD",
                      "offenceDate": "2026-10-28T14:05:00.000Z",
                      "amount": 35,
                      "minoredAmount": null,
                      "collectivityName": "Ville de Lyon",
                      "address": {
                        "line1": "12 rue de la République",
                        "postCodeCity": "69002 Lyon",
                        "latLong": "45.7640,4.8357"
                      },
                      "parkingTicketSentAt": "2026-10-30",
                      "manualInputReason": null,
                      "paymentNoticePath": "https://…/justificatif.pdf?X-Amz-Expires=518400",
                      "createdAt": "2026-11-03T07:12:09.000Z"
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Répondez 200, et seulement 200, sous 15 secondes."
          }
        },
        "tags": [
          "Webhooks"
        ],
        "operationId": "batch-processed"
      }
    },
    "batch.requires_input": {
      "post": {
        "summary": "Lot en erreur",
        "description": "Au moins un FPS du lot demande une intervention (conducteur introuvable, données illisibles…) ; manualInputReason dit lequel et pourquoi.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchEvent"
              },
              "example": {
                "eventType": "batch.requires_input",
                "data": {
                  "uuid": "9f1c2e4a-6b0d-4c55-9a1e-2f7d3c8b5e10",
                  "status": "has_error",
                  "type": "identify",
                  "isFromAntaiApi": true,
                  "organisationId": 42,
                  "createdAt": "2026-11-03T07:12:09.000Z",
                  "processedAt": null,
                  "parkingTickets": [
                    {
                      "uuid": "c03b7d18-2a4f-4e8b-b1c6-7d9e0f1a2b3c",
                      "paymentNotice": "12345678901234567890",
                      "paymentKey": "AB12",
                      "licencePlate": "AB-123-CD",
                      "offenceDate": "2026-10-28T14:05:00.000Z",
                      "amount": 35,
                      "minoredAmount": null,
                      "collectivityName": "Ville de Lyon",
                      "address": {
                        "line1": "12 rue de la République",
                        "postCodeCity": "69002 Lyon",
                        "latLong": "45.7640,4.8357"
                      },
                      "parkingTicketSentAt": "2026-10-30",
                      "manualInputReason": "driver_not_found",
                      "paymentNoticePath": null,
                      "createdAt": "2026-11-03T07:12:09.000Z"
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Répondez 200, et seulement 200, sous 15 secondes."
          }
        },
        "tags": [
          "Webhooks"
        ],
        "operationId": "batch-requires-input"
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key"
      },
      "timestamp": {
        "type": "apiKey",
        "in": "header",
        "name": "x-timestamp"
      },
      "signature": {
        "type": "apiKey",
        "in": "header",
        "name": "x-signature"
      }
    },
    "schemas": {
      "SyncFleetRequest": {
        "type": "object",
        "required": [
          "vehicleAssignments"
        ],
        "additionalProperties": false,
        "properties": {
          "vehicleAssignments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VehicleAssignment"
            },
            "description": "Les affectations à créer ou mettre à jour."
          }
        }
      },
      "VehicleAssignment": {
        "type": "object",
        "required": [
          "licencePlate",
          "startDate",
          "driverEmail"
        ],
        "additionalProperties": false,
        "properties": {
          "licencePlate": {
            "type": "string",
            "pattern": "^[A-Z]{2}-\\d{3}-[A-Z]{2}$",
            "examples": [
              "AB-123-CD"
            ],
            "description": "Plaque au format SIV, en majuscules, avec les tirets."
          },
          "startDate": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2025-02-17T00:00:00+01:00"
            ],
            "description": "Début de l'affectation, ISO 8601."
          },
          "endDate": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "examples": [
              "2025-03-17T00:00:00+01:00"
            ],
            "description": "Fin de l'affectation, ISO 8601. Absente : la fin déjà enregistrée ne change pas. null : l'affectation est rouverte."
          },
          "driverEmail": {
            "type": "string",
            "format": "email",
            "examples": [
              "conducteur@entreprise.fr"
            ],
            "description": "E-mail du conducteur. C'est à lui que l'avis est adressé."
          },
          "additionalEmail1": {
            "type": "string",
            "format": "email",
            "description": "Destinataire en copie, par exemple le manager. Chaîne vide acceptée."
          },
          "additionalEmail2": {
            "type": "string",
            "format": "email",
            "description": "Second destinataire en copie. Chaîne vide acceptée."
          },
          "driverName": {
            "type": "string",
            "description": "Nom du conducteur."
          },
          "driverFirstName": {
            "type": "string",
            "description": "Prénom du conducteur."
          },
          "driverReference": {
            "type": "string",
            "description": "Votre identifiant interne (matricule)."
          },
          "companySiret": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\d{14}$",
            "description": "SIRET de la société de votre groupe qui détient le véhicule. Absent : la société déjà enregistrée ne change pas. Vide ou null : l'organisation elle-même."
          },
          "extraField1": {
            "type": "string",
            "description": "Champ libre, repris dans vos exports. De même extraField2 à extraField7."
          },
          "extraField2": {
            "type": "string"
          },
          "extraField3": {
            "type": "string"
          },
          "extraField4": {
            "type": "string"
          },
          "extraField5": {
            "type": "string"
          },
          "extraField6": {
            "type": "string"
          },
          "extraField7": {
            "type": "string"
          }
        }
      },
      "BatchEvent": {
        "type": "object",
        "properties": {
          "eventType": {
            "type": "string",
            "description": "batch.processed ou batch.requires_input ; repris dans l'en-tête X-Webhook-Event."
          },
          "data": {
            "$ref": "#/components/schemas/Batch"
          }
        },
        "required": [
          "eventType",
          "data"
        ]
      },
      "Batch": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "description": "Identifiant du lot."
          },
          "status": {
            "type": "string",
            "description": "processed, has_error…"
          },
          "type": {
            "type": "string",
            "description": "identify, pay_to_state ou identify_and_pay_to_state."
          },
          "isFromAntaiApi": {
            "type": "boolean",
            "description": "true si les avis sont arrivés directement de l'ANTAI."
          },
          "organisationId": {
            "type": "integer"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "processedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "parkingTickets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ParkingTicket"
            },
            "description": "Les FPS du lot."
          }
        }
      },
      "ParkingTicket": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "description": "Identifiant du FPS."
          },
          "paymentNotice": {
            "type": [
              "string",
              "null"
            ],
            "description": "Numéro de l'avis."
          },
          "paymentKey": {
            "type": [
              "string",
              "null"
            ],
            "description": "Clé de paiement de l'avis."
          },
          "licencePlate": {
            "type": [
              "string",
              "null"
            ]
          },
          "offenceDate": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Date et heure de l'infraction."
          },
          "amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "Montant de l'avis, en euros."
          },
          "minoredAmount": {
            "type": [
              "number",
              "null"
            ],
            "description": "Montant minoré, en euros, quand il existe."
          },
          "collectivityName": {
            "type": [
              "string",
              "null"
            ],
            "description": "Collectivité émettrice."
          },
          "address": {
            "type": [
              "object",
              "null"
            ],
            "description": "Lieu de l'infraction : line1, postCodeCity, latLong."
          },
          "parkingTicketSentAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Date d'envoi de l'avis."
          },
          "manualInputReason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pourquoi le FPS attend une intervention : driver_not_found, several_drivers_found, invalid_data, duplicate_payment_notice, too_old, payment_error… null s'il est traité."
          },
          "paymentNoticePath": {
            "type": [
              "string",
              "null"
            ],
            "description": "Lien de téléchargement du justificatif de traitement, valable 6 jours. Malgré son nom, ce n'est pas l'avis."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "errorCode": {
            "type": [
              "integer",
              "string"
            ],
            "description": "Code de l'erreur ; identique à error.code."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "path": {
            "type": "string",
            "description": "Chemin appelé."
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": [
                  "integer",
                  "string"
                ]
              },
              "message": {
                "type": "string",
                "description": "Détail lisible, quand il est fourni."
              }
            }
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Authentification",
      "description": "Vérifier une clé et sa signature."
    },
    {
      "name": "Flotte",
      "description": "Affectations véhicule → conducteur."
    },
    {
      "name": "Webhooks",
      "description": "Événements que Qasqad envoie à votre SI."
    }
  ]
}
