{
  "openapi": "3.0.3",
  "info": {
    "title": "conv2pdf API",
    "version": "1.0.0",
    "description": "API REST de conversion et de manipulation de PDF, hébergée en France (OVH Gravelines), conforme RGPD, DPA fourni. Aucun service tiers hors UE n'intervient dans le traitement. Les fichiers d'entrée et de sortie sont supprimés au bout d'une heure, aucun résultat n'est mis en cache. Cette API ne connaît aucune exception ; celles de la politique de confidentialité relèvent du site : le titulaire du compte peut y créer un lien de partage pour un job — y compris produit par l'API — et ce fichier vit alors jusqu'à l'échéance du lien ou jusqu'à sa révocation.\n\nAuthentification par clé API (`Authorization: Bearer cpdf_live_...`), à créer depuis le tableau de bord. Un même endpoint `POST /convert/{tool}` expose les 14 outils ; le format de sortie dépend de l'outil.",
    "termsOfService": "https://conv2pdf.com/legal/cgv/",
    "contact": {
      "name": "conv2pdf",
      "url": "https://conv2pdf.com/contact/",
      "email": "contact@conv2pdf.com"
    }
  },
  "servers": [
    { "url": "https://api.conv2pdf.com/v1", "description": "Production" }
  ],
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Conversion", "description": "Conversion et manipulation de fichiers" },
    { "name": "Jobs", "description": "Récupération, statut et suppression des jobs" }
  ],
  "paths": {
    "/tools": {
      "get": {
        "tags": ["Conversion"],
        "summary": "Liste des outils disponibles",
        "description": "Renvoie la liste des 14 outils, leurs bornes de fichiers et extensions acceptées. Source machine de référence pour les intégrations.",
        "operationId": "listTools",
        "responses": {
          "200": {
            "description": "Liste des outils",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ToolsList" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/convert/{tool}": {
      "post": {
        "tags": ["Conversion"],
        "summary": "Convertir ou manipuler un fichier",
        "description": "Effectue une conversion synchrone. Le fichier est envoyé en `multipart/form-data`. La réponse est un JSON contenant l'URL de téléchargement du résultat (récupérable via `GET /download/{jobId}` pendant 1 heure).\n\nCertains outils acceptent des champs de formulaire supplémentaires (voir la propriété `file` et les champs optionnels ci-dessous). `merge-pdf` accepte le champ `file` répété (2 à 20 fichiers).",
        "operationId": "convert",
        "parameters": [
          {
            "name": "tool",
            "in": "path",
            "required": true,
            "description": "Identifiant de l'outil.",
            "schema": {
              "type": "string",
              "enum": [
                "image-to-pdf",
                "heic-to-jpg",
                "heic-to-pdf",
                "office-to-pdf",
                "merge-pdf",
                "split-pdf",
                "compress-pdf",
                "protect-pdf",
                "pdf-to-word",
                "pdf-to-image",
                "rotate-pdf",
                "unlock-pdf",
                "watermark-pdf",
                "page-numbers-pdf"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["file"],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Fichier d'entrée. Extensions acceptées selon l'outil (cf. `GET /tools`). Taille max 10 Mo par fichier sur le plan Dev, 200 Mo sur les plans payants. Pour `merge-pdf`, répéter le champ `file` (2 fichiers sur le plan Dev, jusqu'à 20 sur les plans payants)."
                  },
                  "ranges": {
                    "type": "string",
                    "description": "`split-pdf` (requis) : pages à extraire, ex. `1-5,7,10-12`.",
                    "example": "1-5,7,10-12"
                  },
                  "quality": {
                    "type": "string",
                    "enum": ["low", "medium", "high"],
                    "default": "medium",
                    "description": "`compress-pdf` : niveau de compression (low = 72 DPI, medium = 150 DPI, high = 300 DPI)."
                  },
                  "password": {
                    "type": "string",
                    "description": "`protect-pdf` (requis, 4 à 64 caractères) : mot de passe à poser. `unlock-pdf` (requis) : mot de passe actuel du PDF, à retirer."
                  },
                  "prevent_print": {
                    "type": "string",
                    "enum": ["on"],
                    "description": "`protect-pdf` (optionnel) : interdit l'impression."
                  },
                  "prevent_copy": {
                    "type": "string",
                    "enum": ["on"],
                    "description": "`protect-pdf` (optionnel) : interdit la copie de texte."
                  },
                  "rotation": {
                    "type": "string",
                    "enum": ["90", "180", "270"],
                    "description": "`rotate-pdf` (requis) : angle de rotation en degrés (horaire)."
                  },
                  "text": {
                    "type": "string",
                    "description": "`watermark-pdf` (requis, max 50 caractères) : texte du filigrane.",
                    "example": "CONFIDENTIEL"
                  },
                  "format": {
                    "type": "string",
                    "description": "`pdf-to-image` : `png` (défaut) ou `jpg`. `page-numbers-pdf` : `full` (défaut, « X sur N ») ou `simple` (« X » seul)."
                  },
                  "position": {
                    "type": "string",
                    "enum": ["bottom-center", "bottom-left", "bottom-right"],
                    "default": "bottom-center",
                    "description": "`page-numbers-pdf` (optionnel) : position du numéro."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Conversion réussie",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ConvertSuccess" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PlanLimit" },
          "404": { "$ref": "#/components/responses/ToolNotFound" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "415": { "$ref": "#/components/responses/UnsupportedMedia" },
          "422": { "$ref": "#/components/responses/UnprocessableEntity" },
          "429": { "$ref": "#/components/responses/QuotaExceeded" },
          "500": { "$ref": "#/components/responses/ServerError" },
          "503": { "$ref": "#/components/responses/ServerBusy" },
          "504": { "$ref": "#/components/responses/GatewayTimeout" }
        }
      }
    },
    "/download/{jobId}": {
      "get": {
        "tags": ["Jobs"],
        "summary": "Télécharger le fichier converti",
        "description": "Renvoie le fichier de sortie avec son type MIME (`application/pdf`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` pour PDF → Word, `image/jpeg` pour HEIC → JPG, `application/zip` pour PDF → Image) et `Cache-Control: no-store`. Disponible 1 heure après la conversion.",
        "operationId": "download",
        "parameters": [{ "$ref": "#/components/parameters/JobId" }],
        "responses": {
          "200": {
            "description": "Fichier binaire",
            "content": {
              "application/octet-stream": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/JobNotFound" },
          "409": { "$ref": "#/components/responses/JobNotReady" },
          "410": { "$ref": "#/components/responses/Gone" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/job/{jobId}": {
      "get": {
        "tags": ["Jobs"],
        "summary": "Statut et métadonnées d'un job",
        "description": "Renvoie le statut d'un job (utile pour vérifier qu'une conversion est prête avant de télécharger).",
        "operationId": "getJob",
        "parameters": [{ "$ref": "#/components/parameters/JobId" }],
        "responses": {
          "200": {
            "description": "Statut du job",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JobStatus" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/JobNotFound" },
          "409": { "$ref": "#/components/responses/JobNotReady" },
          "410": { "$ref": "#/components/responses/Gone" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Jobs"],
        "summary": "Supprimer un job",
        "description": "Supprime immédiatement un job et son fichier, sans attendre l'expiration automatique (1 heure).",
        "operationId": "deleteJob",
        "parameters": [{ "$ref": "#/components/parameters/JobId" }],
        "responses": {
          "200": {
            "description": "Job supprimé",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "status": { "type": "string", "example": "deleted" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/JobNotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Clé API au format `cpdf_live_...`, créée depuis le tableau de bord. En-tête `Authorization: Bearer cpdf_live_...`."
      }
    },
    "parameters": {
      "JobId": {
        "name": "jobId",
        "in": "path",
        "required": true,
        "description": "Identifiant du job renvoyé par `POST /convert/{tool}`.",
        "schema": { "type": "string" }
      }
    },
    "schemas": {
      "ToolsList": {
        "type": "object",
        "properties": {
          "tools": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string", "example": "pdf-to-word" },
                "min_files": { "type": "integer", "example": 1 },
                "max_files": { "type": "integer", "example": 1 },
                "accepted_exts": {
                  "type": "array",
                  "items": { "type": "string" },
                  "example": [".pdf"]
                }
              }
            }
          }
        }
      },
      "ConvertSuccess": {
        "type": "object",
        "properties": {
          "job_id": { "type": "string", "example": "abc123def456" },
          "status": { "type": "string", "enum": ["success"] },
          "download_url": { "type": "string", "example": "/v1/download/abc123def456" },
          "size_bytes": { "type": "integer", "example": 124533 },
          "quota": {
            "$ref": "#/components/schemas/Quota",
            "description": "Présent uniquement pour les appels authentifiés par clé API."
          }
        }
      },
      "Quota": {
        "type": "object",
        "description": "État du quota du compte. Sur les plans payants, le quota est mensuel, sur un cycle anniversaire : `quota_period_end` = timestamp de la prochaine remise à zéro, c'est la seule valeur sur laquelle caler un back-off. `period_end` est la borne de FACTURATION, identique à `quota_period_end` en mensuel mais située jusqu'à douze mois plus loin en annuel. Sur le plan Dev, les 300 conversions offertes à l'inscription ne se renouvellent pas et l'essai n'est pas facturé : `quota_period_end` ET `period_end` valent `null`, et le corps porte `upgrade`, le plan qui rouvre l'accès. Un dépassement de 10 % est toléré (`soft_cap_limit`) avant blocage en 429.",
        "properties": {
          "plan": { "type": "string", "example": "starter" },
          "quota": { "type": "integer", "example": 1000 },
          "used": { "type": "integer", "example": 42 },
          "soft_cap_limit": { "type": "integer", "example": 1100 },
          "status": { "type": "string", "enum": ["ok", "soft_cap", "hard_cap"] },
          "period_end": { "type": "integer", "format": "int64", "nullable": true, "example": 1715789012345, "description": "`null` sur le plan Dev : l'essai n'est pas facturé, il n'a donc pas de borne de facturation." },
          "quota_period_end": { "type": "integer", "format": "int64", "nullable": true, "example": 1715789012345, "description": "`null` sur le plan Dev : crédits d'essai, sans renouvellement." },
          "credits_expire_at": { "type": "integer", "format": "int64", "nullable": true, "example": null, "description": "Plan Dev : fin de validité des crédits d'essai (douze mois depuis leur octroi). `null` sur les plans payants." }
        }
      },
      "JobStatus": {
        "type": "object",
        "description": "Renvoyé uniquement pour un job terminé avec succès (sinon 409 `job_not_ready`, ou 410 `job_deleted` si le job a été supprimé).",
        "properties": {
          "job_id": { "type": "string" },
          "status": { "type": "string", "enum": ["success"] },
          "tool": { "type": "string", "example": "pdf-to-word" },
          "size_bytes": { "type": "integer", "example": 124533 },
          "completed_at": { "type": "integer", "format": "int64" },
          "expires_at": { "type": "integer", "format": "int64", "description": "Timestamp de suppression automatique du fichier." },
          "download_url": { "type": "string", "example": "/v1/download/abc123" },
          "can_share": { "type": "boolean" }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "Code d'erreur stable, lisible par machine." }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Nombre de fichiers hors bornes (`not_enough_files`, `too_many_files`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Auth manquante ou clé invalide (`missing_bearer_token`, `invalid_api_key`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PlanLimit": {
        "description": "Limite du plan atteinte. `plan_limit_files` : plus de fichiers que le plan n'en autorise (le corps porte `max_allowed`). `credits_expired` : crédits d'essai Dev passés leur date de validité (douze mois depuis leur octroi), quel que soit le reliquat ; le corps porte `expired_at` et `upgrade`. Aucun outil n'est réservé aux plans payants : les quatorze outils sont servis sur tous les plans, essai compris.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "Le job appartient à une autre clé (`forbidden`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ToolNotFound": {
        "description": "Outil inexistant (`tool_not_found`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "JobNotFound": {
        "description": "Job inexistant (`job_not_found`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "JobNotReady": {
        "description": "Le job existe mais n'a pas de sortie téléchargeable (`job_not_ready`) : conversion en cours, échouée ou rejetée. Le corps porte `status`. Un job supprimé répond 410, pas 409.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Gone": {
        "description": "Ressource partie définitivement : fichier purgé au TTL de 1 h (`file_expired`), ou job supprimé via `DELETE /v1/job/{jobId}` (`job_deleted`). Inutile de retenter.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PayloadTooLarge": {
        "description": "Fichier au-delà de la limite du plan — 10 Mo sur Dev, 200 Mo sur les plans payants (`file_too_large`). Quand un plan supérieur accepterait le fichier, la réponse porte `upgrade` : ce plan, son prix mensuel HT et son plafond.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "file_too_large", "max_bytes": 10485760, "file": "contrat.pdf", "plan": "dev", "upgrade": { "plan": "starter", "price_eur_month": 9, "quota": 1000, "url": "https://conv2pdf.com/api/tarifs/", "max_bytes": 209715200 } } } }
      },
      "UnsupportedMedia": {
        "description": "Deux cas. `unsupported_media_type` : le `Content-Type` de la requête n'est pas parsable — les conversions s'envoient en `multipart/form-data`, et la réponse porte `received` et `accepted`. `unsupported_content` : contenu du fichier incompatible avec l'outil, type déterminé au contenu et pas à l'extension du nom, la réponse porte `detected_type`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "UnprocessableEntity": {
        "description": "Fichier vide (`empty_file`), PDF chiffré (`password_protected`), PDF scanné sans couche texte pour PDF → Word (`pdf_scanned_needs_ocr`), trop de pages (`pdf_too_many_pages`, `too_many_pages`), plage invalide (`invalid_page_range`), rotation invalide (`invalid_rotation`), sortie trop volumineuse (`output_too_large`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "QuotaExceeded": {
        "description": "Deux cas. `quota_exceeded` : quota atteint — sur les plans payants, voir `soft_cap_limit` et `quota_period_end` (date du prochain rechargement), inutile de retenter avant ; sur Dev, `quota_period_end` ET `period_end` valent `null` (rien ne se renouvelle, rien n'est facturé) et le corps porte `upgrade`, le plan qui rouvre l'accès. `rate_limited` : débit dépassé, 20 appels par minute et par clé sur cet endpoint — la réponse porte `retry_after` (secondes) et `limit`, plus l'en-tête `Retry-After`, et le quota n'est pas décompté.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Débit dépassé (`rate_limited`) : 300 requêtes par minute et par IP. La réponse porte `retry_after` (secondes) et `limit`, plus l'en-tête `Retry-After`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "rate_limited", "retry_after": 42, "limit": 300 } } }
      },
      "ServerBusy": {
        "description": "File de conversion saturée, réessayer sous 5 s (`server_busy`, en-tête `Retry-After`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ServerError": {
        "description": "Erreur de conversion serveur (`conversion_failed`) : le corps porte `job_id`, `status: \"failed\"` et, quand il est identifié, `engine`. La tentative n'est pas décomptée du quota.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "GatewayTimeout": {
        "description": "Budget de temps du moteur dépassé (`conversion_timeout`) : le corps porte `engine` et `timeout_ms`, et le job est `failed`. La tentative n'est pas décomptée du quota.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "job_id": "abc123def456", "status": "failed", "error": "conversion_timeout", "engine": "qpdf", "timeout_ms": 30000 } } }
      }
    }
  }
}
