{
  "openapi": "3.1.0",
  "info": {
    "title": "Skilzy Partner API",
    "version": "1.1.0",
    "description": "API REST de Skilzy pour les organismes de formation, authentifiée par clé API d'organisme (Bearer). Principalement en lecture : récupération des données de suivi des apprenants (synthèse, émargement, évaluations, réponses de quiz, documents, satisfaction, progression, connexions, notes) dans Airtable, un tableur, un ETL ou un outil BI. Filtres mono-apprenant (?email= / ?user_id=) et mono-formation (?programme=). Rapport d'activité PDF via GET /activity-report. Une opération d'écriture permet d'inscrire un apprenant (POST /users), réservée aux clés disposant du scope « write ». Des webhooks sortants signés HMAC (configurables depuis le dashboard) notifient les événements importants (voir la clé webhooks de cette spec). Les chiffres sont identiques aux exports tableur et aux dossiers PDF du dashboard.",
    "contact": { "name": "Skilzy", "email": "hello@skilzy.io", "url": "https://skilzy.io/documentation/api" }
  },
  "servers": [
    { "url": "https://skilzy.io/api/v1", "description": "Production" }
  ],
  "security": [{ "bearerApiKey": [] }],
  "tags": [
    { "name": "Ressources", "description": "Lecture des données de l'organisme (GET)" },
    { "name": "Écriture", "description": "Inscription d'apprenants (POST, scope write requis)" }
  ],
  "paths": {
    "/users": {
      "post": {
        "tags": ["Écriture"],
        "operationId": "enrollLearner",
        "summary": "Inscrit un apprenant dans l'organisation",
        "description": "Crée (ou rattache) un apprenant et lui attribue une licence disponible de l'organisation. Réservé aux clés disposant du scope « write ». Si le compte existe déjà, il est rattaché immédiatement (status active) ; sinon une invitation est envoyée (status pending). Refuse (409) si aucune licence n'est disponible. Idempotent par email (un apprenant déjà membre renvoie 409).",
        "security": [{ "bearerApiKey": ["write"] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  { "$ref": "#/components/schemas/EnrollRequest" },
                  { "$ref": "#/components/schemas/BulkEnrollRequest" }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Inscription en lot traitée (best-effort). Récapitulatif + détail par apprenant.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkEnrollResponse" } } }
          },
          "201": {
            "description": "Apprenant inscrit (active) ou invité (pending), inscription unitaire.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnrollResponse" } } }
          },
          "400": { "description": "Requête invalide (email manquant, programme inconnu, hors domaine autorisé…).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Clé API manquante, invalide, révoquée ou expirée.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Clé en lecture seule (scope « write » requis).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "Aucune licence disponible, ou apprenant déjà membre.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "Trop de requêtes (120/min/clé).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitError" } } } }
        }
      }
    },
    "/activity-report": {
      "get": {
        "tags": ["Ressources"],
        "operationId": "getActivityReport",
        "summary": "Rapport d'activité PDF d'un apprenant",
        "description": "Renvoie le dossier d'audit PDF de l'apprenant (même document que le dashboard : connexions, temps par leçon, évaluations, émargement, documents). Cible l'apprenant par ?email= ou ?user_id= (l'un des deux est requis).",
        "parameters": [
          { "$ref": "#/components/parameters/email" },
          { "$ref": "#/components/parameters/userId" },
          { "$ref": "#/components/parameters/programme" },
          {
            "name": "year",
            "in": "query",
            "description": "Année du rapport. Défaut : année en cours.",
            "schema": { "type": "integer", "example": 2026 }
          }
        ],
        "responses": {
          "200": {
            "description": "Le rapport PDF (Content-Disposition: attachment).",
            "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } }
          },
          "400": { "description": "Paramètre email ou user_id manquant.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Clé API manquante, invalide, révoquée ou expirée.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Apprenant introuvable dans l'organisation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "Trop de requêtes (120/min/clé).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitError" } } } }
        }
      }
    },
    "/{resource}": {
      "get": {
        "tags": ["Ressources"],
        "operationId": "getResource",
        "summary": "Lit une ressource de l'organisme",
        "description": "Renvoie les lignes de la ressource demandée, scopées à l'organisation de la clé API. Réponse JSON paginée (enveloppe meta/pagination/data) ou fichier CSV avec ?format=csv. La ressource `summary` renvoie un objet unique dans `data` (sans pagination).",
        "parameters": [
          {
            "name": "resource",
            "in": "path",
            "required": true,
            "description": "Nom de la ressource à interroger.",
            "schema": {
              "type": "string",
              "enum": [
                "learners",
                "users",
                "summary",
                "attendance",
                "evaluations",
                "quiz-answers",
                "documents",
                "satisfaction",
                "enrollments",
                "progress",
                "logins",
                "tutoring-ratings",
                "lesson-ratings"
              ]
            }
          },
          {
            "name": "format",
            "in": "query",
            "description": "Format de sortie.",
            "schema": { "type": "string", "enum": ["json", "csv"], "default": "json" }
          },
          {
            "name": "year",
            "in": "query",
            "description": "Année civile filtrée. Défaut : année en cours. Ignoré pour users/summary.",
            "schema": { "type": "integer", "example": 2026 }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Nombre de lignes par page (JSON). Max 1000.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 100 }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Décalage de pagination (JSON).",
            "schema": { "type": "integer", "minimum": 0, "default": 0 }
          },
          {
            "name": "updated_since",
            "in": "query",
            "description": "Ne renvoie que les lignes dont la date naturelle ≥ valeur (YYYY-MM-DD). Synchronisation incrémentale. Ignoré pour learners (agrégat sans date).",
            "schema": { "type": "string", "format": "date", "example": "2026-06-01" }
          },
          { "$ref": "#/components/parameters/email" },
          { "$ref": "#/components/parameters/userId" },
          { "$ref": "#/components/parameters/programme" }
        ],
        "responses": {
          "200": {
            "description": "Succès. Enveloppe JSON paginée (ou objet unique pour summary), ou fichier CSV.",
            "headers": {
              "X-RateLimit-Limit": { "schema": { "type": "integer" }, "description": "Plafond de requêtes par fenêtre." },
              "X-RateLimit-Remaining": { "schema": { "type": "integer" }, "description": "Requêtes restantes dans la fenêtre." }
            },
            "content": {
              "application/json": {
                "schema": { "oneOf": [{ "$ref": "#/components/schemas/ListResponse" }, { "$ref": "#/components/schemas/SummaryResponse" }] }
              },
              "text/csv": { "schema": { "type": "string", "format": "binary" } }
            }
          },
          "401": {
            "description": "Clé API manquante, invalide, révoquée ou expirée.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "404": {
            "description": "Ressource inconnue.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFoundError" } } }
          },
          "405": {
            "description": "Méthode non autorisée (utilise GET).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": {
            "description": "Trop de requêtes (120/min/clé).",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" }, "description": "Secondes à attendre avant de réessayer." }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitError" } } }
          },
          "500": {
            "description": "Erreur interne.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    }
  },
  "webhooks": {
    "quiz.completed": {
      "post": {
        "summary": "Quiz terminé (avec le détail questions/réponses)",
        "description": "Envoyé à chaque complétion de leçon contenant un quiz. data.answers contient le détail question par question (question, options, réponse choisie, bonne réponse, correct, horodatage). Configuration des endpoints : dashboard OF, page « API & intégrations ». Chaque livraison est signée : X-Skilzy-Signature: t=<unix>,v1=HMAC_SHA256(secret, \"t.corps\"). En-tête X-Skilzy-Delivery = id stable pour la déduplication. Retries : 1 min, 10 min, 1 h, 6 h, 24 h (6 tentatives) puis abandon.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception (répondre en moins de 10 s)." } }
      }
    },
    "course.started": {
      "post": {
        "summary": "Premier lancement d'une formation par un apprenant",
        "description": "Envoyé la toute première fois qu'un apprenant ouvre une leçon d'une formation (première connexion au cours). data : learner, course_id, programme, first_started_at.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception." } }
      }
    },
    "programme.completed": {
      "post": {
        "summary": "Formation complétée (attestation émise)",
        "description": "Envoyé à l'émission de l'attestation de fin. data : learner, programme, total_hours, completion_score, attestation_issued_at.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception." } }
      }
    },
    "learner.enrolled": {
      "post": {
        "summary": "Apprenant inscrit dans l'organisation",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception." } }
      }
    },
    "learner.activated": {
      "post": {
        "summary": "Apprenant activé (première connexion à l'app)",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception." } }
      }
    },
    "satisfaction.submitted": {
      "post": {
        "summary": "Enquête de satisfaction reçue",
        "description": "data : learner, programme, rating (/5), nps (/10), verbatim, answered_at.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "2XX": { "description": "Accusé de réception." } }
      }
    }
  },
  "components": {
    "parameters": {
      "email": {
        "name": "email",
        "in": "query",
        "description": "Restreint à UN apprenant (email exact, insensible à la casse). 404 si inconnu dans l'organisation. Les requêtes mono-apprenant échappent au plafond d'agrégation org-wide (meta.truncated).",
        "schema": { "type": "string", "format": "email", "example": "apprenant@orga.fr" }
      },
      "userId": {
        "name": "user_id",
        "in": "query",
        "description": "Comme email, mais par identifiant technique de l'apprenant.",
        "schema": { "type": "string", "format": "uuid" }
      },
      "programme": {
        "name": "programme",
        "in": "query",
        "description": "Restreint à UNE formation (slug de programme, ex. marketing-contenus-rs7439). Ignoré sur les ressources sans programme (learners, logins).",
        "schema": { "type": "string", "example": "marketing-contenus-rs7439" }
      }
    },
    "securitySchemes": {
      "bearerApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "sk_live_…",
        "description": "Clé API d'organisme, générée depuis la page « API & intégrations » du dashboard. En-tête : Authorization: Bearer sk_live_xxx. Scopes : lecture (read) par défaut ; l'écriture (write, ex. POST /users) nécessite une clé créée avec l'option « Autoriser l'écriture ». Une clé peut avoir une date d'expiration : passé cette date elle renvoie 401."
      }
    },
    "schemas": {
      "Meta": {
        "type": "object",
        "properties": {
          "org_name": { "type": "string", "example": "Mon OF" },
          "year": { "type": "integer", "example": 2026 },
          "generated_at": { "type": "string", "format": "date-time" },
          "truncated": { "type": "boolean", "description": "true si le volume dépasse le cap synchrone (toujours false pour les requêtes mono-apprenant)." },
          "updated_since": { "type": ["string", "null"], "format": "date" },
          "learner": {
            "type": "object",
            "description": "Présent uniquement pour les requêtes mono-apprenant (?email= / ?user_id=).",
            "properties": {
              "user_id": { "type": "string", "format": "uuid" },
              "email": { "type": ["string", "null"], "format": "email" }
            }
          },
          "programme": { "type": ["string", "null"], "description": "Slug du filtre ?programme= s'il est actif." }
        }
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "Enveloppe commune des webhooks sortants. Signature : X-Skilzy-Signature: t=<unix>,v1=HMAC_SHA256(secret whsec_…, \"t.corps brut\"). Rejeter les horodatages de plus de 5 minutes (anti-rejeu).",
        "required": ["id", "type", "created_at", "data"],
        "properties": {
          "id": { "type": "string", "format": "uuid", "description": "Id de l'événement (stable à travers les retries)." },
          "type": {
            "type": "string",
            "enum": ["quiz.completed", "course.started", "programme.completed", "learner.enrolled", "learner.activated", "satisfaction.submitted", "ping"]
          },
          "created_at": { "type": "string", "format": "date-time" },
          "data": {
            "type": "object",
            "additionalProperties": true,
            "properties": {
              "learner": {
                "type": ["object", "null"],
                "properties": {
                  "user_id": { "type": "string", "format": "uuid" },
                  "email": { "type": ["string", "null"] },
                  "name": { "type": ["string", "null"] }
                }
              }
            }
          }
        }
      },
      "Pagination": {
        "type": "object",
        "properties": {
          "limit": { "type": "integer", "example": 100 },
          "offset": { "type": "integer", "example": 0 },
          "count": { "type": "integer", "example": 42 },
          "total": { "type": "integer", "example": 42 },
          "has_more": { "type": "boolean", "example": false }
        }
      },
      "ListResponse": {
        "type": "object",
        "properties": {
          "resource": { "type": "string", "example": "learners" },
          "meta": { "$ref": "#/components/schemas/Meta" },
          "pagination": { "$ref": "#/components/schemas/Pagination" },
          "data": {
            "type": "array",
            "items": { "type": "object", "additionalProperties": true },
            "description": "Lignes de la ressource. Les clés exactes dépendent de la ressource ; voir la section « Référence des champs » de la documentation."
          }
        }
      },
      "SummaryResponse": {
        "type": "object",
        "description": "Réponse de la ressource summary : data est un objet unique (pas un tableau).",
        "properties": {
          "resource": { "type": "string", "example": "summary" },
          "meta": { "$ref": "#/components/schemas/Meta" },
          "data": {
            "type": "object",
            "properties": {
              "utilisateurs": {
                "type": "object",
                "properties": {
                  "total": { "type": "integer", "example": 38 },
                  "actifs": { "type": "integer", "example": 31 }
                }
              },
              "licences": {
                "type": "object",
                "properties": {
                  "standard": {
                    "type": "object",
                    "properties": {
                      "total": { "type": "integer", "example": 50 },
                      "disponibles": { "type": "integer", "example": 12 },
                      "utilisees": { "type": "integer", "example": 38 }
                    }
                  },
                  "tutorat": {
                    "type": "object",
                    "properties": {
                      "total": { "type": "integer", "example": 10 },
                      "disponibles": { "type": "integer", "example": 4 },
                      "utilisees": { "type": "integer", "example": 6 }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "EnrollRequest": {
        "type": "object",
        "required": ["email"],
        "properties": {
          "email": { "type": "string", "format": "email", "example": "apprenant@orga.fr" },
          "programme_slugs": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Slugs des programmes à attribuer (clé stable, ex. \"marketing-contenus-rs7439\").",
            "example": ["marketing-contenus-rs7439"]
          }
        }
      },
      "EnrollResponse": {
        "type": "object",
        "properties": {
          "member": { "type": "object", "additionalProperties": true, "description": "La ligne org_members créée." },
          "status": { "type": "string", "enum": ["active", "pending"], "description": "active si le compte existait déjà, pending sinon (invitation envoyée)." }
        }
      },
      "BulkEnrollRequest": {
        "type": "object",
        "required": ["users"],
        "properties": {
          "users": {
            "type": "array",
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/EnrollRequest" },
            "description": "Liste d'apprenants à inscrire (max 50 par appel)."
          },
          "atomic": {
            "type": "boolean",
            "default": false,
            "description": "Si true : rejette tout le lot (409) si les licences disponibles sont insuffisantes, sans inscrire personne."
          }
        }
      },
      "BulkEnrollResponse": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "object",
            "properties": {
              "requested": { "type": "integer", "example": 50 },
              "enrolled": { "type": "integer", "example": 47 },
              "failed": { "type": "integer", "example": 3 }
            }
          },
          "results": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "email": { "type": "string", "format": "email" },
                "status": { "type": "string", "enum": ["active", "pending"], "description": "Présent si la ligne a réussi." },
                "member_id": { "type": ["string", "null"] },
                "error": { "type": "string", "description": "Présent si la ligne a échoué (ex. licence indisponible, déjà membre)." }
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": { "error": { "type": "string", "example": "Clé API invalide" } },
        "required": ["error"]
      },
      "NotFoundError": {
        "type": "object",
        "properties": {
          "error": { "type": "string", "example": "Ressource inconnue : foo" },
          "available": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["error"]
      },
      "RateLimitError": {
        "type": "object",
        "properties": {
          "error": { "type": "string", "example": "Trop de requêtes. Limite : 120/60s." },
          "retry_after": { "type": "integer", "example": 42 }
        },
        "required": ["error"]
      }
    }
  }
}
