{
  "openapi": "3.1.0",
  "info": {
    "title": "Signatory Hub API",
    "version": "2.0.0",
    "summary": "Service de signature électronique générique, mutualisé entre les apps NVAPPS.",
    "description": "Signatory Hub fait signer un document sans que l'app cliente réimplémente la signature.\n\n**Le flux tient en quatre temps :**\n\n1. L'app crée une demande — `POST /api/requests` avec sa clé API. Elle reçoit un `public_url`.\n2. L'app transmet ce lien au signataire (par ses propres moyens : email, SMS, affichage).\n3. Le signataire ouvre le lien, reçoit un code à 6 chiffres par email, signe au doigt ou à la souris.\n4. Signatory Hub notifie l'app par **webhook signé** (voir la section `webhooks`), et tient à\n   disposition un **PDF de preuve** horodaté via `GET /api/requests/{id}/proof`.\n\nL'app peut aussi interroger `GET /api/requests/{id}` en polling si elle ne peut pas exposer de webhook.\n\n**Signatory Hub ne lit jamais la base d'une autre app** : chaque app reste maître de ses données et\nstocke le résultat chez elle.",
    "contact": {
      "name": "NVAPPS",
      "url": "https://signatoryhub.com"
    }
  },
  "servers": [
    {
      "url": "https://signatoryhub.com",
      "description": "Instance de production"
    }
  ],
  "tags": [
    {
      "name": "Mon app",
      "description": "Configuration de l'app par elle-même, avec sa clé API — sans passer par la console d'administration."
    },
    {
      "name": "Demandes",
      "description": "Cycle de vie des demandes de signature. Authentification par clé API de l'app."
    },
    {
      "name": "Preuve",
      "description": "Justificatif téléchargeable d'une signature enregistrée, scellé par sign-hub, et sa vérification publique."
    },
    {
      "name": "Signature",
      "description": "Endpoints publics consommés par la page de signature. Le token de l'URL fait office d'authentification."
    },
    {
      "name": "Service",
      "description": "Santé et découvrabilité."
    },
    {
      "name": "Admin",
      "description": "Console d'administration. Jeton Supabase d'un compte `admin` / `super_admin`."
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "Clé de l'app cliente (`shk_…`), créée dans la console. Elle n'est affichée qu'une seule fois : le serveur n'en conserve qu'une empreinte SHA-256 salée."
      },
      "adminBearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Jeton de session Supabase d'un compte administrateur."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string",
            "description": "Message destiné à l'utilisateur. Le détail technique reste dans les logs du serveur."
          }
        },
        "required": [
          "success",
          "error"
        ],
        "examples": [
          {
            "success": false,
            "error": "Clé API invalide"
          }
        ]
      },
      "AppConfig": {
        "type": "object",
        "description": "Configuration de l'app. `webhook_secret` n'y figure jamais : il se récupère dans la console d'administration.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "inactive"
            ]
          },
          "api_key_prefix": {
            "type": "string",
            "description": "Début de la clé, pour l'identifier sans la révéler."
          },
          "webhook_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "URL de repli, utilisée quand une demande ne porte pas de `callback_url`."
          },
          "webhook_events": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Événements souscrits. Par défaut `[\"request.signed\"]`."
          },
          "allowed_callback_hosts": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Hôtes autorisés à recevoir vos webhooks. **Lecture seule** : c'est la garde anti-SSRF, seul un administrateur peut l'élargir. Une liste vide autorise tout hôte public."
          },
          "legal_name": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Raison sociale affichée au signataire (page, emails). À défaut, le nom de l'app."
          },
          "logo_url": {
            "type": "string",
            "nullable": true,
            "format": "uri",
            "description": "Logo (https), affiché sur la page de signature et dans les emails."
          },
          "brand_color": {
            "type": "string",
            "nullable": true,
            "pattern": "^#[0-9a-f]{6}$",
            "description": "Couleur d'accent `#rrggbb`."
          },
          "webhook_secret_set": {
            "type": "boolean",
            "description": "Indique qu'un secret existe, sans le divulguer."
          },
          "last_used_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Delivery": {
        "type": "object",
        "description": "Tentative de notification vers votre endpoint.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "event": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "sent",
              "failed"
            ],
            "description": "`sent` reçu · `pending` reprise programmée · `failed` abandonné après le plafond de tentatives."
          },
          "attempts": {
            "type": "integer"
          },
          "status_code": {
            "type": "integer",
            "nullable": true,
            "description": "Code HTTP renvoyé par votre endpoint."
          },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "Cause de l'échec (timeout, DNS, code HTTP…)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "delivered_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "Summary": {
        "type": "object",
        "description": "Résumé du document, affiché tel quel au signataire. Structure libre, mais ces trois clés sont mises en forme automatiquement.",
        "properties": {
          "intro": {
            "type": "string",
            "description": "Paragraphe d'introduction."
          },
          "items": {
            "type": "array",
            "description": "Lignes du document (libellé / valeur).",
            "items": {
              "type": "object",
              "properties": {
                "label": {
                  "type": "string"
                },
                "value": {
                  "type": "string"
                }
              }
            }
          },
          "total": {
            "type": "string",
            "description": "Montant total, mis en évidence."
          },
          "client_ref": {
            "type": "string",
            "description": "Votre référence interne (id de devis, de contrat…). **Renvoyée telle quelle dans le webhook** : c'est le moyen recommandé de recoller la signature à votre document, le token étant éphémère."
          }
        }
      },
      "CreateRequest": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 200,
            "description": "Titre affiché au signataire.",
            "examples": [
              "Devis DC-2026-0142"
            ]
          },
          "summary": {
            "$ref": "#/components/schemas/Summary",
            "description": "Résumé du document. 32 Ko maximum une fois sérialisé, 200 lignes `items` au plus."
          },
          "signer_name": {
            "type": "string",
            "maxLength": 120,
            "description": "Nom pré-rempli. Le signataire peut le corriger."
          },
          "signer_email": {
            "type": "string",
            "format": "email",
            "maxLength": 254,
            "description": "Adresse du signataire. **Sans elle, la vérification par code à 6 chiffres ne peut pas s'appliquer** et la demande reste signable par simple possession du lien."
          },
          "callback_url": {
            "type": "string",
            "format": "uri",
            "description": "URL de votre webhook pour cette demande. À défaut, le `webhook_url` configuré sur l'app est utilisé. Doit être publique (les IP privées sont refusées) et figurer dans les hôtes autorisés de l'app s'ils sont renseignés."
          },
          "expires_in_days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 365,
            "description": "Validité du lien, en jours (1 à 365). Défaut : réglage `signature_ttl_days` (30 jours)."
          },
          "document": {
            "type": "object",
            "description": "**Le document lui-même (PDF)**, 10 Mo maximum. Affiché au signataire, son SHA-256 est figé à la création et entre dans `document_hash`. Une version signée (original + page de signature, scellée PAdES) est disponible sur `/api/requests/{id}/signed.pdf`. Fournir `base64` OU `url` (publique : la même garde anti-SSRF que les webhooks s'applique, sans suivi de redirection).",
            "properties": {
              "base64": {
                "type": "string",
                "description": "Contenu du PDF en base64 (préfixe `data:application/pdf;base64,` toléré)."
              },
              "url": {
                "type": "string",
                "format": "uri",
                "description": "URL http(s) d'où télécharger le PDF."
              },
              "filename": {
                "type": "string",
                "maxLength": 200,
                "default": "document.pdf"
              }
            }
          },
          "consent_text": {
            "type": "string",
            "maxLength": 1000,
            "description": "Texte de la case de consentement affichée au signataire. Défaut : réglage `consent_text_default`. Consigné dans la preuve."
          },
          "lock_signer_name": {
            "type": "boolean",
            "default": true,
            "description": "Si `signer_name` est fourni, le signataire ne peut pas le modifier (défaut). `false` pour laisser le champ libre."
          },
          "signer_identity": {
            "type": "object",
            "description": "Identité du signataire telle que VOTRE app la connaît (compte connecté, OIDC…). sign-hub la consigne dans la preuve sans la vérifier.",
            "required": [
              "provider",
              "subject"
            ],
            "properties": {
              "provider": {
                "type": "string",
                "maxLength": 200,
                "examples": [
                  "devicrafto"
                ]
              },
              "subject": {
                "type": "string",
                "maxLength": 200,
                "description": "Identifiant stable chez vous.",
                "examples": [
                  "user_8f21c"
                ]
              },
              "method": {
                "type": "string",
                "maxLength": 200,
                "nullable": true,
                "examples": [
                  "password+2fa",
                  "oidc:google"
                ]
              },
              "verified": {
                "type": "boolean",
                "default": false,
                "description": "Vous attestez avoir vérifié cette identité."
              }
            }
          },
          "redirect_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048,
            "description": "Où renvoyer le signataire après signature (lien + redirection automatique après 8 s, annulable)."
          },
          "signers": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "**Plusieurs signataires.** À partir de deux, chacun reçoit son propre lien (`signers[].public_url` dans la réponse), son code, sa case de consentement ; la demande passe en `signed` quand tous ont signé. Un seul élément équivaut aux champs plats `signer_name` / `signer_email` / `signer_identity`.",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string",
                  "maxLength": 120
                },
                "email": {
                  "type": "string",
                  "format": "email",
                  "description": "Requis pour le code de vérification et l'envoi par sign-hub."
                },
                "identity": {
                  "type": "object",
                  "description": "Comme `signer_identity`."
                },
                "lock_name": {
                  "type": "boolean",
                  "default": true
                }
              }
            }
          },
          "signing_order": {
            "type": "string",
            "enum": [
              "parallel",
              "sequential"
            ],
            "default": "parallel",
            "description": "`sequential` : chaque signataire ne peut agir qu'après le précédent (son lien affiche « pas encore votre tour ») ; avec `send_email`, le suivant reçoit son email au moment voulu."
          },
          "send_email": {
            "type": "boolean",
            "default": false,
            "description": "**sign-hub envoie le lien** au signataire par email (aux couleurs de votre app), au lieu de vous laisser le faire. Requiert `signer_email` (ou un email par signataire) et une messagerie configurée sur le service (sinon `409`)."
          },
          "reminder_days": {
            "type": "integer",
            "minimum": 1,
            "maximum": 30,
            "description": "Avec `send_email` : rappel automatique tous les N jours sans signature, jusqu'à `reminder_max` fois (réglage, 3 par défaut). Chaque rappel émet `request.reminded`."
          }
        },
        "examples": [
          {
            "title": "Devis DC-2026-0142",
            "summary": {
              "intro": "Rénovation de la salle de bain, 12 rue des Lilas.",
              "items": [
                {
                  "label": "Fourniture et pose",
                  "value": "3 200,00 €"
                },
                {
                  "label": "Évacuation des gravats",
                  "value": "250,00 €"
                }
              ],
              "total": "3 450,00 € TTC",
              "client_ref": "quote_8f21c"
            },
            "signer_name": "Marie Durand",
            "signer_email": "marie.durand@example.com",
            "callback_url": "https://devicrafto.fr/api/webhooks/sign-hub",
            "expires_in_days": 15
          }
        ]
      },
      "CreatedRequest": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifiant à conserver : il sert au polling et au téléchargement de la preuve."
          },
          "token": {
            "type": "string",
            "format": "uuid",
            "description": "Secret du lien public. À ne pas journaliser."
          },
          "public_url": {
            "type": "string",
            "format": "uri",
            "description": "Lien à transmettre au signataire."
          }
        },
        "examples": [
          {
            "id": "9c1f2b7e-4d3a-4a91-9a55-2b7c0e1f8d44",
            "token": "d4a1e0b2-9c77-4f31-8a0e-51b6c2d9e7a3",
            "public_url": "https://sign-hub.newvisionofapps.fr/s/d4a1e0b2-9c77-4f31-8a0e-51b6c2d9e7a3"
          }
        ]
      },
      "RequestStatus": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "viewed",
              "signed",
              "expired",
              "cancelled"
            ],
            "description": "`pending` créée · `viewed` lien ouvert · `signed` signée · `expired` échue · `cancelled` annulée par l'app."
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "signer_name": {
            "type": "string",
            "nullable": true
          },
          "signer_email": {
            "type": "string",
            "nullable": true
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "document_hash": {
            "type": "string",
            "nullable": true,
            "description": "SHA-256 du contenu signé, figé au moment de la signature."
          },
          "document_name": {
            "type": "string",
            "nullable": true,
            "description": "Nom du PDF joint, s'il y en a un."
          },
          "document_sha256": {
            "type": "string",
            "nullable": true,
            "description": "SHA-256 du PDF joint, figé à la création."
          },
          "consent_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "anonymized_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Date d'anonymisation RGPD (IP, navigateur, email effacés)."
          },
          "sent_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Lien envoyé par sign-hub (`send_email`)."
          },
          "reminder_count": {
            "type": "integer",
            "nullable": true
          },
          "idempotency_key": {
            "type": "string",
            "nullable": true
          },
          "signing_order": {
            "type": "string",
            "enum": [
              "parallel",
              "sequential"
            ],
            "nullable": true
          },
          "signers_count": {
            "type": "integer",
            "nullable": true,
            "description": "1 = demande classique ; > 1 = demande multiple."
          },
          "signed_count": {
            "type": "integer",
            "nullable": true
          },
          "signers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SignerStatus"
            },
            "description": "Demande multiple uniquement : état et **lien de chaque signataire** (`public_url`), à distribuer par votre app si `send_email` n'est pas utilisé."
          },
          "signature_data": {
            "type": "string",
            "nullable": true,
            "description": "Image PNG en data-URL. Présente uniquement une fois la demande signée."
          }
        }
      },
      "RequestList": {
        "type": "object",
        "properties": {
          "requests": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RequestStatus"
            }
          },
          "total": {
            "type": "integer",
            "description": "Nombre total de demandes correspondant au filtre."
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "ProofBundle": {
        "type": "object",
        "description": "Dossier de preuve canonique, figé à la signature, suivi de son enveloppe : empreinte (`proof_hash`), **sceau Ed25519 de sign-hub** (`proof_signature`, `proof_kid`), horodatage tiers éventuel (`tsa`) et où le vérifier. Le sceau porte sur la sérialisation canonique (clés triées, à toute profondeur) du dossier SANS les champs de l'enveloppe. Il se vérifie hors ligne avec la clé publique de `/.well-known/sign-hub-keys.json`, ou en ligne via `POST /api/verify` : renvoyez simplement ce document tel quel.",
        "properties": {
          "request_id": {
            "type": "string",
            "format": "uuid"
          },
          "app_name": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "document_hash": {
            "type": "string",
            "description": "SHA-256 de `{title, summary}` sérialisé avec les clés triées."
          },
          "signer_name": {
            "type": "string",
            "nullable": true
          },
          "signer_email": {
            "type": "string",
            "nullable": true
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "signer_ip": {
            "type": "string",
            "nullable": true
          },
          "signer_user_agent": {
            "type": "string",
            "nullable": true
          },
          "otp_verified_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Horodatage de validation du code à 6 chiffres. `null` si la vérification ne s'appliquait pas."
          },
          "signature_sha256": {
            "type": "string",
            "nullable": true
          },
          "consent_text": {
            "type": "string",
            "description": "Présent quand le consentement a été consigné (signatures postérieures à la v1.3)."
          },
          "consent_at": {
            "type": "string",
            "format": "date-time"
          },
          "signer_identity": {
            "type": "object",
            "description": "Identité déclarée par l'app émettrice, telle que fournie à la création."
          },
          "document_name": {
            "type": "string",
            "nullable": true,
            "description": "Présent quand un PDF était joint."
          },
          "document_sha256": {
            "type": "string"
          },
          "proof_hash": {
            "type": "string",
            "description": "SHA-256 du dossier ci-dessus (enveloppe exclue)."
          },
          "proof_signature": {
            "type": "string",
            "nullable": true,
            "description": "Signature Ed25519 (base64) de la sérialisation canonique du dossier. `null` si le service n'avait pas de clé de sceau au moment de la signature."
          },
          "proof_kid": {
            "type": "string",
            "nullable": true,
            "description": "Identifiant de la clé publique à utiliser (voir `/.well-known/sign-hub-keys.json`)."
          },
          "seal_alg": {
            "type": "string",
            "nullable": true,
            "enum": [
              "Ed25519",
              null
            ]
          },
          "tsa": {
            "type": "object",
            "nullable": true,
            "description": "Jeton d'horodatage RFC 3161 portant sur `proof_hash`, si une autorité est configurée.",
            "properties": {
              "url": {
                "type": "string"
              },
              "time": {
                "type": "string",
                "format": "date-time"
              },
              "token": {
                "type": "string",
                "description": "TimeStampToken DER en base64."
              }
            }
          },
          "verify_url": {
            "type": "string",
            "format": "uri"
          },
          "keys_url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "SignerStatus": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "position": {
            "type": "integer",
            "description": "0 = premier."
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "viewed",
              "signed"
            ]
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "viewed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "sent_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "reminder_count": {
            "type": "integer"
          },
          "token": {
            "type": "string",
            "format": "uuid",
            "description": "Uniquement dans les réponses à VOTRE app (création, détail) : c'est le secret d'accès de ce signataire."
          },
          "public_url": {
            "type": "string",
            "format": "uri",
            "description": "Lien à transmettre à CE signataire (idem)."
          }
        }
      },
      "Articles": {
        "type": "object",
        "properties": {
          "articles": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string",
                  "description": "Adresse de l’article : `/conseils/{slug}`."
                },
                "title": {
                  "type": "string"
                },
                "excerpt": {
                  "type": "string",
                  "nullable": true
                },
                "reading_minutes": {
                  "type": "integer",
                  "nullable": true
                },
                "published_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "tags": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          }
        }
      },
      "Plans": {
        "type": "object",
        "properties": {
          "currency": {
            "type": "string",
            "example": "eur"
          },
          "saas_enabled": {
            "type": "boolean",
            "description": "Quotas appliqués sur ce service."
          },
          "signup_enabled": {
            "type": "boolean",
            "description": "Inscription publique ouverte (`/signup`)."
          },
          "plans": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "examples": [
                    "free",
                    "starter",
                    "pro",
                    "pack25"
                  ]
                },
                "name": {
                  "type": "string"
                },
                "kind": {
                  "type": "string",
                  "enum": [
                    "subscription",
                    "pack"
                  ]
                },
                "monthly_price_cents": {
                  "type": "integer"
                },
                "included_signatures": {
                  "type": "integer",
                  "description": "Actes inclus par mois (abonnement)."
                },
                "pack_size": {
                  "type": "integer",
                  "description": "Actes crédités (pack)."
                },
                "pack_price_cents": {
                  "type": "integer"
                },
                "act_price_cents": {
                  "type": "integer",
                  "nullable": true,
                  "description": "Prix unitaire de l'acte (pack)."
                },
                "purchasable": {
                  "type": "boolean",
                  "description": "Achetable en ligne (tarif Stripe configuré)."
                }
              }
            }
          }
        }
      },
      "QuotaExceeded": {
        "type": "object",
        "description": "Réponse `429` de `POST /api/requests` quand le quota mensuel de l'organisation est atteint et qu'aucun crédit n'est disponible. Les en-têtes `X-SignHub-Quota-Limit`, `X-SignHub-Quota-Remaining` et `X-SignHub-Credits` accompagnent aussi les réponses `200`.",
        "properties": {
          "success": {
            "type": "boolean",
            "example": false
          },
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "example": "quota_exceeded"
          },
          "quota": {
            "type": "object",
            "properties": {
              "limit": {
                "type": "integer"
              },
              "used": {
                "type": "integer"
              },
              "remaining": {
                "type": "integer"
              },
              "credits": {
                "type": "integer"
              },
              "period": {
                "type": "string",
                "example": "2026-09"
              }
            }
          },
          "upgrade_url": {
            "type": "string",
            "format": "uri",
            "description": "Espace client : changer d'offre ou acheter un pack."
          }
        }
      },
      "VerifyResult": {
        "type": "object",
        "properties": {
          "valid": {
            "type": "boolean",
            "description": "`true` si l'empreinte correspond ET que le sceau est valide (et l'horodatage, s'il est présent)."
          },
          "reason": {
            "type": "string",
            "description": "Pourquoi `valid` est `false`, en clair."
          },
          "kid": {
            "type": "string",
            "nullable": true
          },
          "computed_hash": {
            "type": "string",
            "description": "SHA-256 recalculé sur le dossier fourni."
          },
          "checks": {
            "type": "object",
            "properties": {
              "hash": {
                "type": "boolean"
              },
              "signature": {
                "type": "boolean"
              },
              "timestamp": {
                "type": "boolean"
              }
            }
          },
          "known": {
            "type": "boolean",
            "description": "Présent si `valid` : la demande existe dans ce service avec cette empreinte."
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "signer_name": {
            "type": "string",
            "nullable": true
          },
          "app_name": {
            "type": "string",
            "nullable": true
          },
          "tsa_time": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "PublicKeys": {
        "type": "object",
        "properties": {
          "service": {
            "type": "string",
            "example": "sign-hub"
          },
          "keys": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kid": {
                  "type": "string"
                },
                "alg": {
                  "type": "string",
                  "example": "Ed25519"
                },
                "use": {
                  "type": "string",
                  "example": "proof"
                },
                "public_key_pem": {
                  "type": "string",
                  "description": "Clé publique SPKI au format PEM."
                }
              }
            }
          },
          "verify_url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "SignView": {
        "type": "object",
        "properties": {
          "request": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string",
                "nullable": true
              },
              "summary": {
                "$ref": "#/components/schemas/Summary"
              },
              "company": {
                "type": "string",
                "description": "Nom de l'app émettrice."
              },
              "status": {
                "type": "string",
                "enum": [
                  "pending",
                  "viewed",
                  "signed",
                  "expired",
                  "cancelled"
                ]
              },
              "signed": {
                "type": "boolean"
              },
              "signer_name": {
                "type": "string",
                "nullable": true
              },
              "signed_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "otp_required": {
                "type": "boolean",
                "description": "Si vrai, un code doit être demandé puis fourni pour signer."
              },
              "consent_text": {
                "type": "string",
                "description": "Texte de la case de consentement à afficher."
              },
              "lock_signer_name": {
                "type": "boolean"
              },
              "has_document": {
                "type": "boolean",
                "description": "Un PDF est joint : `GET /api/sign/{token}/document` l'affiche."
              },
              "document_name": {
                "type": "string",
                "nullable": true
              },
              "redirect_url": {
                "type": "string",
                "nullable": true
              },
              "my_status": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "pending",
                  "viewed",
                  "signed",
                  null
                ],
                "description": "Demande multiple : l'état du signataire porteur de ce lien."
              },
              "signing_order": {
                "type": "string",
                "nullable": true,
                "enum": [
                  "parallel",
                  "sequential",
                  null
                ]
              },
              "signers": {
                "type": "array",
                "nullable": true,
                "items": {
                  "$ref": "#/components/schemas/SignerStatus"
                },
                "description": "Demande multiple : tous les signataires (sans leurs liens), `is_me` sur le porteur."
              },
              "waiting_for": {
                "type": "object",
                "nullable": true,
                "properties": {
                  "position": {
                    "type": "integer"
                  },
                  "name": {
                    "type": "string"
                  }
                },
                "description": "Séquentiel : le signataire qui doit signer avant. Tant qu'il est présent, `POST` répond `409`."
              }
            }
          }
        }
      },
      "WebhookPayload": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "request.created",
              "request.viewed",
              "request.signed",
              "request.expired",
              "request.cancelled",
              "request.reminded",
              "signer.signed",
              "ping"
            ]
          },
          "delivery_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifiant de la livraison. **Utilisez-le pour dédupliquer** : un rejeu manuel réémet le même `delivery_id`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifiant de la demande."
          },
          "token": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string"
          },
          "client_ref": {
            "type": "string",
            "nullable": true,
            "description": "Votre référence, recopiée depuis `summary.client_ref`."
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "signer_name": {
            "type": "string",
            "nullable": true
          },
          "signer_email": {
            "type": "string",
            "nullable": true
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "signer_ip": {
            "type": "string",
            "nullable": true
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "signature_data": {
            "type": "string",
            "nullable": true,
            "description": "PNG en data-URL. Présent uniquement sur `request.signed`."
          },
          "document_hash": {
            "type": "string",
            "nullable": true,
            "description": "Présent uniquement sur `request.signed`."
          },
          "proof_hash": {
            "type": "string",
            "nullable": true,
            "description": "Présent uniquement sur `request.signed`."
          },
          "proof_signature": {
            "type": "string",
            "nullable": true,
            "description": "Sceau Ed25519 du dossier de preuve (base64). Uniquement sur `request.signed` ; `null` si le service n'a pas de clé de sceau."
          },
          "proof_kid": {
            "type": "string",
            "nullable": true,
            "description": "Identifiant de la clé de sceau. Uniquement sur `request.signed`."
          },
          "consent_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Uniquement sur `request.signed`."
          },
          "signer_identity": {
            "type": "object",
            "nullable": true,
            "description": "Uniquement sur `request.signed`."
          },
          "document_sha256": {
            "type": "string",
            "nullable": true,
            "description": "Uniquement sur `request.signed`. Le document signé : `GET /api/requests/{id}/signed.pdf`."
          },
          "signer": {
            "$ref": "#/components/schemas/SignerStatus",
            "description": "Demande multiple : le signataire concerné (sur `signer.signed`)."
          },
          "signers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SignerStatus"
            },
            "description": "Demande multiple : l'état de tous les signataires (jamais leurs liens)."
          },
          "signing_order": {
            "type": "string",
            "enum": [
              "parallel",
              "sequential"
            ]
          },
          "signed_count": {
            "type": "integer"
          }
        }
      }
    }
  },
  "security": [
    {
      "apiKey": []
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "tags": [
          "Service"
        ],
        "summary": "État du service",
        "security": [],
        "description": "Sert aussi de point de découverte : la réponse porte les URL de la documentation.",
        "responses": {
          "200": {
            "description": "Service en ligne",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "ok"
                    },
                    "service": {
                      "type": "string",
                      "const": "sign-hub"
                    },
                    "db": {
                      "type": "boolean"
                    },
                    "docs_url": {
                      "type": "string"
                    },
                    "openapi_url": {
                      "type": "string"
                    },
                    "llms_url": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/app": {
      "get": {
        "tags": [
          "Mon app"
        ],
        "summary": "Lire sa configuration",
        "operationId": "getApp",
        "description": "Renvoie la configuration de l'app porteuse de la clé, et la liste des événements souscriptibles.",
        "responses": {
          "200": {
            "description": "Configuration",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "app": {
                      "$ref": "#/components/schemas/AppConfig"
                    },
                    "subscribable_events": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Mon app"
        ],
        "summary": "Configurer son webhook",
        "operationId": "updateApp",
        "description": "Règle l'URL de repli et les événements souscrits, **sans compte administrateur**.\nPratique pour qu'une app configure sa chaîne de notification depuis son propre script de déploiement.\n\nModifiables : `webhook_url`, `webhook_events` et l'identité visuelle (`legal_name`, `logo_url`, `brand_color`).\nToute tentative sur `name`, `status`, `allowed_callback_hosts`, `webhook_secret`, `api_key_hash` ou\n`api_key_prefix` est refusée en `403` — un refus explicite, jamais un champ silencieusement ignoré.\n\nPasser `\"\"` retire une valeur (URL de repli, logo…).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "legal_name": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 200
                  },
                  "logo_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "https uniquement."
                  },
                  "brand_color": {
                    "type": "string",
                    "nullable": true,
                    "pattern": "^#[0-9a-fA-F]{6}$"
                  },
                  "webhook_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "URL publique HTTPS. Doit figurer dans `allowed_callback_hosts` si cette liste est renseignée."
                  },
                  "webhook_events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "request.created",
                        "request.viewed",
                        "request.signed",
                        "request.expired",
                        "request.cancelled",
                        "request.reminded",
                        "signer.signed"
                      ]
                    },
                    "description": "`ping` ne se souscrit pas : il se déclenche à la demande."
                  }
                }
              },
              "examples": {
                "Souscrire signature et consultation": {
                  "value": {
                    "webhook_url": "https://votre-app.fr/api/webhooks/sign-hub",
                    "webhook_events": [
                      "request.signed",
                      "request.viewed"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Configuration mise à jour",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "app": {
                      "$ref": "#/components/schemas/AppConfig"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "URL non publique, hôte non autorisé, événement inconnu, ou rien à modifier.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide, ou champ réservé à l'administrateur.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/webhook-test": {
      "post": {
        "tags": [
          "Mon app"
        ],
        "summary": "Tester son webhook (ping)",
        "operationId": "testWebhook",
        "description": "Envoie un événement `ping` à votre endpoint et renvoie le **résultat réel de la livraison** :\ncode HTTP obtenu, nombre de tentatives, cause de l'échec le cas échéant.\n\nValide d'un coup l'URL, le secret, le réseau et votre récepteur — sans créer ni signer de vraie demande.\n\nLe `ping` porte `\"status\": \"ping\"` : un récepteur qui ne traite que `request.signed` doit donc\nrépondre `200` et l'ignorer. C'est le comportement attendu.\n\nÀ utiliser après un déploiement ou une rotation de secret. Limité à 10 appels par minute :\nil sollicite un serveur tiers.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "URL à tester. À défaut, le `webhook_url` configuré."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ping émis. `ok` vaut `true` seulement si votre endpoint a répondu 2xx.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "delivery": {
                      "$ref": "#/components/schemas/Delivery"
                    }
                  }
                },
                "examples": {
                  "Endpoint joignable": {
                    "value": {
                      "ok": true,
                      "delivery": {
                        "event": "ping",
                        "status": "sent",
                        "attempts": 1,
                        "status_code": 200
                      }
                    }
                  },
                  "Signature refusée par le récepteur": {
                    "value": {
                      "ok": false,
                      "delivery": {
                        "event": "ping",
                        "status": "pending",
                        "attempts": 1,
                        "status_code": 401,
                        "error": "HTTP 401"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Aucune URL configurée, URL non publique, ou hôte non autorisé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Trop de tests. Voir `Retry-After`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests": {
      "post": {
        "tags": [
          "Demandes"
        ],
        "summary": "Créer une demande de signature",
        "operationId": "createRequest",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Votre référence (id de document…). Une relance avec la même clé renvoie **la même demande** (`idempotent_replay: true`, en-tête `Idempotent-Replayed: true`) au lieu d'en créer une seconde."
          }
        ],
        "description": "Renvoie le lien public à transmettre au signataire.\n\nPar défaut Signatory Hub **n'envoie pas** ce lien : c'est à votre app de le faire (email, SMS, affichage direct).\nAvec `send_email: true`, Signatory Hub l'envoie lui-même, aux couleurs de votre app, et relance selon `reminder_days`.\nPensez à renseigner `signer_email`, faute de quoi la vérification par code ne peut pas s'appliquer.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Demande créée",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatedRequest"
                }
              }
            }
          },
          "400": {
            "description": "URL de callback invalide, non publique, ou hôte non autorisé pour cette app.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide ou app désactivée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Trop de requêtes (`Retry-After` en secondes), **ou quota mensuel atteint** : `code: quota_exceeded`, objet `quota`, `upgrade_url`. Dans ce second cas, ne retentez pas en boucle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuotaExceeded"
                }
              }
            }
          },
          "503": {
            "description": "Fonction indisponible : migration de base non appliquée (document, identité, idempotence, envoi d'email, multi-signataires).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Demandes"
        ],
        "summary": "Lister ses demandes",
        "operationId": "listRequests",
        "description": "Ne renvoie que les demandes de l'app porteuse de la clé. **`signature_data` est omis dans la liste** (jusqu'à 400 Ko par ligne) : lisez le détail d'une demande pour l'obtenir.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "viewed",
                "signed",
                "expired",
                "cancelled"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RequestList"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}": {
      "get": {
        "tags": [
          "Demandes"
        ],
        "summary": "Consulter une demande",
        "operationId": "getRequest",
        "description": "Polling, si votre app ne peut pas exposer de webhook. Une demande d'une autre app renvoie `404`, jamais `403` : rien ne fuite sur l'existence des demandes voisines.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Statut de la demande",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RequestStatus"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/cancel": {
      "post": {
        "tags": [
          "Demandes"
        ],
        "summary": "Annuler une demande",
        "operationId": "cancelRequest",
        "description": "Rend le lien inopérant. Une demande déjà signée ne peut pas être annulée.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Demande annulée",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "status": {
                      "type": "string",
                      "const": "cancelled"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande déjà signée ou déjà annulée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/proof": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Télécharger le PDF de preuve",
        "operationId": "getProofPdf",
        "description": "PDF récapitulant le contenu signé, l'image de la signature et les éléments de preuve\n(nom, horodatage, IP, navigateur, validation du code email, empreintes SHA-256).\n\nLe fichier est **déterministe** : deux téléchargements produisent le même octet, donc la même\nempreinte. Vous pouvez donc citer son SHA-256 dans vos propres archives.\n\nRéservé à l'app qui a créé la demande.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF de preuve",
            "headers": {
              "X-SignHub-Proof-Hash": {
                "schema": {
                  "type": "string"
                },
                "description": "SHA-256 du dossier de preuve."
              },
              "X-SignHub-Document-Hash": {
                "schema": {
                  "type": "string"
                },
                "description": "SHA-256 du contenu signé."
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande non signée : il n'y a pas encore de preuve à produire.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/remind": {
      "post": {
        "tags": [
          "Demandes"
        ],
        "summary": "Relancer le signataire par email",
        "operationId": "remindRequest",
        "description": "Signatory Hub envoie (ou renvoie) le lien de signature au `signer_email`, aux couleurs de votre app. Délai minimal entre deux envois : réglage `remind_cooldown_minutes` (60 par défaut) → `429` avec `Retry-After`. Émet `request.reminded`. Fonctionne même si la demande a été créée sans `send_email`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Envoyé",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "reminder_count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande close (signée, annulée) ou sans email.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Lien expiré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Trop tôt depuis le dernier envoi.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Messagerie du service non configurée ou indisponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/document": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Télécharger le PDF joint (original)",
        "operationId": "getDocument",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Le PDF tel que fourni à la création. En-tête `X-SignHub-Document-Sha256`.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable, ou sans document joint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/signed.pdf": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Télécharger le document signé",
        "operationId": "getSignedDocument",
        "description": "L'original, plus une page de signature (image, nom, date, consentement, identité déclarée, empreintes, sceau), le tout **scellé PAdES** quand un certificat est configuré. Déterministe : deux téléchargements donnent le même fichier.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF signé. En-têtes `X-SignHub-Document-Sha256`, `X-SignHub-Proof-Hash`, `X-SignHub-Sealed`.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable, ou sans document joint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande non signée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/anonymize": {
      "post": {
        "tags": [
          "Demandes"
        ],
        "summary": "Anonymiser une demande signée (RGPD)",
        "operationId": "anonymizeRequest",
        "description": "Efface IP, navigateur et email des colonnes interrogeables (droit à l'effacement). Le **dossier de preuve scellé est conservé** : c'est la pièce probante, et l'altérer casserait le sceau. Le réglage `retention_days` fait la même chose automatiquement après N jours. Idempotent.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fait",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "anonymized": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Clé API invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande non signée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/sign/{token}/document": {
      "get": {
        "tags": [
          "Signature"
        ],
        "summary": "PDF joint, pour la page de signature",
        "security": [],
        "operationId": "viewDocument",
        "description": "L'original tant que la demande est ouverte ; la version signée une fois la signature enregistrée. Affiché dans la page hébergée par sign-hub.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Lien invalide, ou pas de document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/sign/{token}/proof": {
      "get": {
        "tags": [
          "Signature"
        ],
        "summary": "Justificatif pour le signataire",
        "security": [],
        "operationId": "signerProof",
        "description": "Le même PDF de preuve que celui remis à l'app émettrice, téléchargeable par le signataire depuis l'écran de confirmation.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF de preuve",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Lien invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Document non signé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/verify": {
      "post": {
        "tags": [
          "Preuve"
        ],
        "summary": "Vérifier un dossier de preuve",
        "security": [],
        "operationId": "verifyProof",
        "description": "Public, sans clé. Envoyez un `proof.json` tel que remis par `GET /api/requests/{id}/proof.json` (ou par le webhook `request.signed` recomposé). Recalcule l'empreinte, vérifie le sceau Ed25519 contre les clés publiées et, le cas échéant, le jeton d'horodatage. Ne révèle rien : la réponse ne parle que du document fourni. Toujours `200` — regardez `valid`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProofBundle"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Résultat de la vérification",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifyResult"
                }
              }
            }
          },
          "429": {
            "description": "Trop de requêtes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/legal": {
      "get": {
        "tags": [
          "Service"
        ],
        "summary": "Identité légale de l'éditeur",
        "security": [],
        "operationId": "getLegalIdentity",
        "description": "Public (LCEN art. 6-III). Alimente le pied de page et les pages `/legal/*`. `territories` liste les territoires où le service est proposé — la souscription est refusée ailleurs.",
        "responses": {
          "200": {
            "description": "Identité",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "identity": {
                      "type": "object",
                      "description": "Raison sociale, forme, capital, SIREN/SIRET, RCS, TVA, adresse, contact, directeur de la publication, hébergeurs, territoire."
                    },
                    "territories": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string"
                          },
                          "label": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plans": {
      "get": {
        "tags": [
          "Service"
        ],
        "summary": "Offres et tarifs",
        "security": [],
        "operationId": "getPlans",
        "description": "Public. Les offres SaaS : plan gratuit, abonnements mensuels (actes inclus) et packs d'actes sans abonnement. Un **acte** = une demande de signature créée. `saas_enabled` indique si les quotas sont appliqués sur ce service ; les organisations internes sont illimitées.",
        "responses": {
          "200": {
            "description": "Offres",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Plans"
                }
              }
            }
          }
        }
      }
    },
    "/api/articles": {
      "get": {
        "tags": [
          "Service"
        ],
        "summary": "Derniers articles publiés",
        "security": [],
        "operationId": "getArticles",
        "description": "Public. Les articles du blog les plus récents, pour afficher un fil d’actualité depuis une autre page. Les brouillons ne sont jamais renvoyés. Les pages sont rendues en HTML sur `/conseils` et le fil RSS sur `/conseils/rss.xml`.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12,
              "default": 3
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Articles",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Articles"
                }
              }
            }
          }
        }
      }
    },
    "/webhooks/inbox/{id}": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Boîte de réception d’essai",
        "security": [],
        "operationId": "postWebhookInbox",
        "description": "Public. Récepteur de démonstration : il accepte n'importe quel corps JSON, répond 200 et conserve en mémoire les dernières livraisons, en-têtes de signature compris. Sert à éprouver un webhook quand on n'a pas encore de serveur public à exposer. L'adresse est propre à chaque organisation et se lit dans la console (Bac à sable). Rien n'est conservé au-delà des 25 dernières réceptions, ni après un redémarrage : ce sont des essais, pas des données.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "received": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/proof-exemple": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Dossier de preuve de démonstration",
        "security": [],
        "operationId": "getProofExample",
        "description": "Public. Un dossier de preuve portant sur un document FICTIF, mais RÉELLEMENT scellé avec la clé en service. Déposé sur `/verify` ou vérifié hors ligne avec la clé publiée, il est valide ; un seul caractère modifié et il ne l'est plus. Sert à éprouver la vérification sans créer de demande.",
        "responses": {
          "200": {
            "description": "Dossier de preuve",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProofBundle"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/sign-hub-keys.json": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Clés publiques du sceau",
        "security": [],
        "operationId": "getPublicKeys",
        "description": "Pour vérifier une preuve **hors ligne** : `verify(Ed25519, canonical(bundle), base64decode(proof_signature))` avec la clé dont le `kid` correspond à `proof_kid`. Les anciennes clés restent publiées après rotation.",
        "responses": {
          "200": {
            "description": "Clés",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicKeys"
                }
              }
            }
          }
        }
      }
    },
    "/api/requests/{id}/proof.json": {
      "get": {
        "tags": [
          "Preuve"
        ],
        "summary": "Récupérer le dossier de preuve (JSON)",
        "operationId": "getProofBundle",
        "description": "Même contenu que le PDF, sous forme exploitable par programme — pour archiver les empreintes ou revérifier une preuve.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Dossier de preuve",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProofBundle"
                }
              }
            }
          },
          "404": {
            "description": "Demande introuvable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Demande non signée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/sign/{token}": {
      "get": {
        "tags": [
          "Signature"
        ],
        "summary": "Vue publique du document",
        "security": [],
        "operationId": "viewSignRequest",
        "description": "Consommé par la page de signature. Le premier appel bascule la demande en `viewed`. Ne renvoie jamais le `callback_url` ni les données de l'app.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Document à signer",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignView"
                }
              }
            }
          },
          "404": {
            "description": "Lien invalide ou expiré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Signature"
        ],
        "summary": "Enregistrer la signature",
        "security": [],
        "operationId": "submitSignature",
        "description": "Écriture atomique : une seconde signature concurrente est refusée et ne déclenche pas de second webhook.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "signature",
                  "consent"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 120,
                    "description": "Doit être identique à `signer_name` quand la demande est verrouillée (`lock_signer_name`)."
                  },
                  "consent": {
                    "type": "boolean",
                    "description": "Doit valoir `true` : la case de consentement a été cochée. Le texte affiché et l'instant sont consignés dans la preuve."
                  },
                  "signature": {
                    "type": "string",
                    "description": "PNG en data-URL (`data:image/png;base64,…`), 400 ko maximum."
                  },
                  "code": {
                    "type": "string",
                    "pattern": "^\\d{6}$",
                    "description": "Code reçu par email. Exigé dès qu'un `signer_email` est associé et que la vérification est active."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signature enregistrée",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Nom ou image de signature invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Code incorrect. Le message indique le nombre de tentatives restantes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lien invalide.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Document déjà signé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Lien expiré, ou code expiré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "428": {
            "description": "Un code de vérification doit d'abord être demandé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Trop de tentatives : un nouveau code est nécessaire.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/sign/{token}/otp": {
      "post": {
        "tags": [
          "Signature"
        ],
        "summary": "Envoyer un code de vérification",
        "security": [],
        "operationId": "sendOtp",
        "description": "Envoie un code à 6 chiffres à l'adresse du signataire. L'adresse renvoyée est masquée, la page étant publique.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Code envoyé",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "sent_to": {
                      "type": "string",
                      "examples": [
                        "ma••••@example.com"
                      ]
                    },
                    "expires_in": {
                      "type": "integer",
                      "description": "Durée de validité, en secondes."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Vérification par code désactivée sur ce service.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Aucune adresse email associée, ou document déjà signé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Lien expiré.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Un code vient d'être envoyé : patienter (voir `Retry-After`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Messagerie indisponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "request.signed": {
      "post": {
        "summary": "Le document a été signé",
        "description": "Envoyé à `callback_url` (ou au `webhook_url` de l'app) dès qu'une signature est enregistrée.\n\n**Vérifiez toujours la signature HMAC avant de traiter le corps.** Deux en-têtes sont émis :\n\n| En-tête | Calcul | Usage |\n|---|---|---|\n| `X-SignHub-Signature` | `sha256=HMAC_SHA256(corps, secret)` | Historique, toujours émis |\n| `X-SignHub-Signature-V2` | `sha256=HMAC_SHA256(timestamp + \".\" + corps, secret)` | **Recommandé** : rejetez au-delà de 5 minutes d'écart avec `X-SignHub-Timestamp` |\n\nLe `secret` est le *webhook secret* de votre app, visible dans la console (onglet Apps → Intégration).\nComparez en temps constant (`crypto.timingSafeEqual`), jamais avec `===`.\n\n**Répondez `2xx` rapidement.** Toute autre réponse déclenche une reprise avec backoff exponentiel\n(5 tentatives par défaut). Les livraisons sont journalisées et rejouables depuis la console.\n\nUtilisez `delivery_id` pour dédupliquer : un rejeu porte le même identifiant.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu. Toute réponse 2xx clôt la livraison."
          },
          "default": {
            "description": "Toute autre réponse provoque une nouvelle tentative."
          }
        }
      }
    },
    "request.created": {
      "post": {
        "summary": "Demande créée",
        "description": "Nécessite un `webhook_url` sur l'app (à la création, aucune demande ne porte encore de `callback_url` côté récepteur). À souscrire explicitement.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "request.viewed": {
      "post": {
        "summary": "Lien ouvert par le signataire",
        "description": "Émis une seule fois, au premier accès. Utile pour relancer un signataire qui n'a pas ouvert le lien.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "request.expired": {
      "post": {
        "summary": "Demande échue",
        "description": "Émis par le balayage périodique quand `expires_at` est dépassé sans signature.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "request.cancelled": {
      "post": {
        "summary": "Demande annulée",
        "description": "Émis après `POST /api/requests/{id}/cancel`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "request.reminded": {
      "post": {
        "summary": "Rappel envoyé",
        "description": "Émis quand sign-hub a envoyé un email de rappel au signataire (rappel automatique `reminder_days`, ou `POST /api/requests/{id}/remind`).",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "signer.signed": {
      "post": {
        "summary": "Un signataire a signé (demande multiple)",
        "description": "Émis à chaque signature d'une demande à plusieurs signataires ; `signer` désigne lequel, `signers` donne l'état de tous. `request.signed` ne part que lorsque le dernier a signé.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    },
    "ping": {
      "post": {
        "summary": "Test de configuration",
        "description": "Déclenché depuis la console (bouton « Tester »). Ignore l'abonnement aux événements.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reçu"
          }
        }
      }
    }
  }
}