# Signatory Hub — API de signature électronique > Document de référence destiné à un assistant de génération de code. > Version 1.1.0 — spec machine : https://signatoryhub.com/openapi.json — exploration : https://signatoryhub.com/docs ## En une phrase Votre app crée une demande de signature, reçoit un lien public à transmettre au signataire, et est notifiée par webhook signé quand le document est signé. Un PDF de preuve horodaté reste téléchargeable ensuite. Signatory Hub ne lit jamais votre base de données et n'envoie pas le lien lui-même : c'est votre app qui transmet le lien et qui stocke le résultat. ## URL de base https://signatoryhub.com ## Authentification En-tête `x-api-key: shk_...` sur tous les endpoints `/api/requests*`. La clé est créée dans la console Signatory Hub (onglet Apps) et n'est affichée qu'une seule fois. Ne jamais la placer dans du code front-end : elle permet de créer des demandes au nom de votre app. Les endpoints `/api/sign/{token}` sont publics : le token de l'URL fait office d'authentification (il est à traiter comme un secret, donc à ne pas journaliser). ## Cycle de vie d'une demande pending → viewed → signed ↓ ↓ expired / cancelled - `pending` : créée, lien pas encore ouvert - `viewed` : le signataire a ouvert le lien - `signed` : signature enregistrée (état terminal) - `expired` : `expires_at` dépassé sans signature (posé par un balayage périodique) - `cancelled` : annulée par votre app ## Configurer son webhook (sans compte admin) Une app peut lire et régler sa propre configuration avec sa seule clé API. C'est la façon recommandée d'automatiser la mise en place depuis un script de déploiement. GET /api/app Headers: x-api-key → { "app": { "name", "status", "api_key_prefix", "webhook_url", "webhook_events", "allowed_callback_hosts", "webhook_secret_set", "legal_name", "logo_url", "brand_color" }, "subscribable_events": [...] } PATCH /api/app Headers: x-api-key, Content-Type: application/json Corps : { "webhook_url": "...", "webhook_events": [...] } curl -X PATCH https://signatoryhub.com/api/app \ -H "x-api-key: $SIGN_HUB_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webhook_url": "https://votre-app.fr/api/webhooks/sign-hub", "webhook_events": ["request.signed", "request.viewed"] }' Seuls `webhook_url` et `webhook_events` sont modifiables. Toute tentative sur `name`, `status`, `allowed_callback_hosts`, `webhook_secret`, `api_key_hash` ou `api_key_prefix` renvoie **403** avec le nom du champ refusé — jamais un champ silencieusement ignoré. Deux éléments restent volontairement hors de portée de la clé API : - `allowed_callback_hosts` est en lecture seule : c'est la garde anti-SSRF. Une app qui pourrait l'élargir elle-même la viderait de son sens. - `webhook_secret` n'est jamais renvoyé. La clé API et le secret sont deux justificatifs distincts : si la clé fuit, l'attaquant peut créer des demandes, mais il ne doit pas pouvoir forger un webhook « document signé » vers votre app. Le secret se récupère dans la console (Apps -> Intégration). Passer `"webhook_url": ""` retire l'URL de repli. ## Tester son webhook (ping) POST /api/app/webhook-test Headers: x-api-key Corps (facultatif) : { "url": "https://..." } // à défaut, le webhook_url configuré curl -X POST https://signatoryhub.com/api/app/webhook-test \ -H "x-api-key: $SIGN_HUB_API_KEY" -H "Content-Type: application/json" -d '{}' Réponse — le **résultat réel** de la livraison, pas un simple accusé : { "ok": true, "delivery": { "event": "ping", "status": "sent", "attempts": 1, "status_code": 200, "delivered_at": "..." } } `ok` ne vaut `true` que si votre endpoint a répondu 2xx. En cas d'échec, `delivery.status_code` et `delivery.error` disent pourquoi : { "ok": false, "delivery": { "status": "pending", "attempts": 1, "status_code": 401, "error": "HTTP 401" } } Le ping porte `"status": "ping"` : un récepteur qui ne traite que `request.signed` doit donc répondre 200 et l'ignorer — c'est le comportement attendu, et cela valide quand même toute la chaîne (URL, secret, réseau). À lancer après un déploiement ou une rotation de secret. Limité à 10 appels par minute, puisqu'il sollicite un serveur tiers. ## Endpoints ### 1. Créer une demande POST /api/requests Headers: x-api-key, Content-Type: application/json Corps : title string Titre affiché au signataire summary object Résumé du document (voir plus bas) signer_name string? Nom pré-rempli signer_email string? IMPORTANT : sans email, pas de code de vérification callback_url string? Votre webhook pour cette demande expires_in_days number? Entier de 1 à 365. Défaut 30 document object? { base64 | url, filename? } — LE PDF à signer (10 Mo max) consent_text string? Texte de la case de consentement (défaut : réglage) lock_signer_name bool? Défaut true : le nom fourni n'est pas modifiable signer_identity object? { provider, subject, method?, verified? } — identité connue de VOTRE app redirect_url string? Où renvoyer le signataire après signature send_email bool? sign-hub envoie le lien par email (défaut false : VOUS l'envoyez) reminder_days number? Avec send_email : rappel tous les N jours (1..30), reminder_max fois signers array? Plusieurs signataires : [{ name, email, identity?, lock_name? }] (≤ 10) signing_order string? parallel (défaut) | sequential — voir « Plusieurs signataires » En-tête optionnel Idempotency-Key: (200 caractères max) → une relance avec la même clé renvoie LA MÊME demande (id, token, public_url) avec "idempotent_replay": true et l'en-tête Idempotent-Replayed: true, au lieu d'en créer une seconde. Utilisez l'id de votre document. Bornes (au-delà : 400 avec le champ en cause) : title 200 caractères, signer_name 120, signer_email 254 et de forme valide, summary 32 Ko une fois sérialisé, summary.items 200 lignes. Réponse 200 : { "id": "uuid", "token": "uuid", "public_url": "https://signatoryhub.com/s/" } curl -X POST https://signatoryhub.com/api/requests \ -H "x-api-key: $SIGN_HUB_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "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://votre-app.fr/api/webhooks/sign-hub" }' Le champ `summary.client_ref` est votre référence interne (id de devis, de contrat…). Elle est **renvoyée telle quelle dans le webhook** : c'est le moyen recommandé de recoller la signature à votre document. N'utilisez pas le token pour cela, il est éphémère. ### 2. Lister ses demandes GET /api/requests?status=signed&limit=50&offset=0 Headers: x-api-key → { "requests": [...], "total": n, "limit": 50, "offset": 0 } (sans signature_data : l'image ne vient qu'avec le détail d'une demande) ### 3. Consulter une demande (polling) GET /api/requests/{id} Headers: x-api-key → { "id", "status", "title", "signer_name", "signed_at", "expires_at", "created_at", "document_hash", "signature_data" } À n'utiliser que si votre app ne peut pas exposer de webhook. Espacez les appels (60 s au minimum) : la route est limitée à 120 requêtes par minute et par IP. ### 4. Annuler une demande POST /api/requests/{id}/cancel Headers: x-api-key → { "ok": true, "status": "cancelled" } 409 si la demande est déjà signée. ### 5. Télécharger la preuve GET /api/requests/{id}/proof → application/pdf GET /api/requests/{id}/proof.json → dossier de preuve JSON Headers: x-api-key Réservé à l'app qui a créé la demande. 409 tant que la demande n'est pas signée. Le PDF contient le contenu signé, l'image de la signature, le nom, l'horodatage, l'IP, le navigateur, la validation du code email et deux empreintes SHA-256. Il est **déterministe** : deux téléchargements donnent le même fichier, donc la même empreinte — vous pouvez archiver son SHA-256 comme référence. curl https://signatoryhub.com/api/requests/$ID/proof \ -H "x-api-key: $SIGN_HUB_API_KEY" -o preuve.pdf Le dossier JSON expose `document_hash` (SHA-256 du contenu présenté au signataire, figé à la signature) et `proof_hash` (SHA-256 du dossier complet). Conservez-les : ils démontrent que le document n'a pas été modifié après coup. ## Offres, quotas et espace client Un ACTE = une demande de signature créée (POST /api/requests), quel que soit le nombre de signataires ou l'issue. Les offres (GET https://signatoryhub.com/api/plans, public) : plan gratuit (actes/mois inclus), abonnements mensuels, packs d'actes sans abonnement. Les organisations internes sont illimitées. Quand les quotas sont appliqués (saas_enabled) : - chaque réponse 200 de POST /api/requests porte X-SignHub-Quota-Limit, X-SignHub-Quota-Remaining et X-SignHub-Credits ; - au-delà du quota mensuel sans crédit : 429 avec { "success": false, "code": "quota_exceeded", "error": "…", "quota": { "limit", "used", "remaining", "credits", "period" }, "upgrade_url": "…" }. Ne retentez pas en boucle : changez d'offre ou achetez un pack depuis https://signatoryhub.com/app. - l'espace client (https://signatoryhub.com/app) donne clés API, secrets, webhooks, identité visuelle, demandes, usage du mois et facturation (Stripe). ## Plusieurs signataires POST https://signatoryhub.com/api/requests { "title": "Contrat de partenariat", "document": { "url": "https://votre-app.fr/files/contrat.pdf" }, "signing_order": "sequential", "signers": [ { "name": "Alice Martin", "email": "alice@a.fr" }, { "name": "Bob Durand", "email": "bob@b.fr", "identity": { "provider": "votre-app", "subject": "u_42", "verified": true } } ], "send_email": true } → { "id", "token", "public_url", // public_url = lien du PREMIER signataire "signing_order": "sequential", "signers": [ { "id", "position": 0, "name", "email", "status": "pending", "token", "public_url" }, … ] } - Chaque signataire a SON lien (signers[].public_url), son code de vérification, sa case de consentement, sa signature. Distribuez les liens vous-même, ou laissez send_email le faire. - sequential : le 2e ne peut agir qu'après le 1er (sa page affiche « pas encore votre tour », POST /api/sign → 409). Avec send_email, il reçoit son email au bon moment. parallel : tous en même temps. - Événements : signer.signed à chaque signature (payload.signer = qui, payload.signers = l'état de tous) ; request.signed quand le DERNIER a signé. - GET /api/requests/{id} : signers_count, signed_count, signers[] avec liens. - Dossier de preuve et PDF : une section par signataire (image, date, IP, code, consentement, identité) ; les champs de premier niveau (signer_name…) reflètent le premier signataire. - Un seul élément dans signers = demande classique (équivaut aux champs plats). ## Laisser sign-hub envoyer le lien et relancer Par défaut VOUS transmettez public_url au signataire. Avec send_email: true, sign-hub le fait (via sa messagerie), aux couleurs de votre app (logo, couleur, raison sociale — voir « Identité visuelle »), et peut relancer : POST https://signatoryhub.com/api/requests { ..., "signer_email": "marie@example.com", "send_email": true, "reminder_days": 3 } → { "id", "token", "public_url", "email_sent": "pending" } POST https://signatoryhub.com/api/requests/{id}/remind → rappel manuel (délai minimal entre deux : 1 h → 429) → { "ok": true, "reminder_count": 1 } Chaque rappel (auto ou manuel) émet l'événement webhook request.reminded. Champs de suivi : sent_at, reminder_count (GET /api/requests/{id}). 409 si la messagerie du service n'est pas configurée : envoyez vous-même. ## Identité visuelle (page de signature, emails) curl -X PATCH https://signatoryhub.com/api/app -H "x-api-key: $SIGN_HUB_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "legal_name": "Ma Société SAS", "logo_url": "https://votre-app.fr/logo.png", "brand_color": "#0f766e" }' legal_name (200 c.), logo_url (https, affiché ≤ 48 px de haut), brand_color (#rrggbb). Chaîne vide = retirer. Lisible sur GET /api/app. ## Joindre le document (PDF) et renforcer la signature Sans `document`, le signataire ne voit qu'un résumé. Avec, il voit le PDF lui-même (rendu natif dans la page), son SHA-256 est figé à la création et entre dans `document_hash`, et vous récupérez un DOCUMENT SIGNÉ : l'original + une page de signature (image, nom, date, consentement, identité déclarée, empreintes, sceau), scellé PAdES. curl -X POST https://signatoryhub.com/api/requests \ -H "x-api-key: $SIGN_HUB_API_KEY" -H "Content-Type: application/json" \ -d "{ \"title\": \"Contrat de prestation\", \"signer_name\": \"Marie Durand\", \"signer_email\": \"marie@example.com\", \"document\": { \"base64\": \"$(base64 -w0 contrat.pdf)\", \"filename\": \"contrat.pdf\" }, \"signer_identity\": { \"provider\": \"votre-app\", \"subject\": \"user_8f21c\", \"method\": \"password+2fa\", \"verified\": true }, \"redirect_url\": \"https://votre-app.fr/contrats/8f21c?signed=1\" }" # ou par URL publique (même garde anti-SSRF que les webhooks, pas de redirection) : # "document": { "url": "https://votre-app.fr/files/contrat-8f21c.pdf" } Après signature : GET https://signatoryhub.com/api/requests/{id}/signed.pdf → le document signé (application/pdf) GET https://signatoryhub.com/api/requests/{id}/document → l'original tel que fourni Ce qui change aussi, avec ou sans document : - Consentement EXPLICITE : le signataire coche une case (`consent_text`, ou le texte par défaut). Le texte et l'instant sont dans le dossier de preuve (`consent_text`, `consent_at`) et sur le PDF. POST /api/sign exige `consent: true`. - Nom verrouillé : si vous passez `signer_name`, il n'est plus modifiable (`lock_signer_name: false` pour l'autoriser). Un autre nom → 400. - `signer_identity` : ce que vous savez du signataire (compte connecté, OIDC…). sign-hub ne le vérifie pas, il le consigne — dans la preuve et le webhook. - `redirect_url` : lien de retour + redirection automatique après 8 s. - Le signataire télécharge son propre justificatif (`GET /api/sign/{token}/proof`) et le document signé depuis l'écran de confirmation. - RGPD : `POST /api/requests/{id}/anonymize` efface IP, navigateur et email des colonnes ; le réglage `retention_days` le fait automatiquement. Le dossier de preuve SCELLÉ est conservé (c'est la pièce probante). Champ `anonymized_at`. ## Sceau numérique : prouver qu'une preuve vient de sign-hub Un SHA-256 seul ne prouve pas l'origine. Le dossier de preuve est donc SIGNÉ par sign-hub (Ed25519) au moment même de la signature : proof.json = { ...dossier, proof_hash, proof_signature, proof_kid, seal_alg, tsa, verify_url, keys_url } - `proof_signature` : signature Ed25519 (base64) de la sérialisation CANONIQUE du dossier (clés triées à toute profondeur, JSON compact, UTF-8) — enveloppe exclue (proof_hash, proof_signature, proof_kid, seal_alg, tsa, verify_url, keys_url). - `proof_kid` : quelle clé publique utiliser. Clés sur https://signatoryhub.com/.well-known/sign-hub-keys.json - `tsa` : jeton d'horodatage RFC 3161 (DER base64) sur proof_hash, si activé. - Le PDF de preuve est en plus scellé PAdES (signature visible dans Acrobat). ### Vérifier en ligne (aucune clé) curl -X POST https://signatoryhub.com/api/verify -H "Content-Type: application/json" \ -d @proof.json → { "valid": true, "checks": { "hash": true, "signature": true }, "known": true, "kid": "…", "signed_at": "…", "signer_name": "…", "app_name": "…" } → { "valid": false, "reason": "proof_hash ne correspond pas au contenu : …" } Page pour un humain (glisser le fichier) : https://signatoryhub.com/verify ### Vérifier hors ligne (Node, sans appeler sign-hub) import { createPublicKey, verify, createHash } from 'node:crypto'; const canonical = (v) => v === null || typeof v !== 'object' ? JSON.stringify(v ?? null) : Array.isArray(v) ? '[' + v.map(canonical).join(',') + ']' : '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'; const ENVELOPE = ['proof_hash', 'proof_signature', 'proof_kid', 'seal_alg', 'tsa', 'verify_url', 'keys_url']; export async function verifyProof(proof, keysUrl = 'https://signatoryhub.com/.well-known/sign-hub-keys.json') { const bundle = Object.fromEntries(Object.entries(proof).filter(([k]) => !ENVELOPE.includes(k))); const bytes = Buffer.from(canonical(bundle), 'utf8'); if (createHash('sha256').update(bytes).digest('hex') !== proof.proof_hash) return { valid: false, reason: 'contenu modifié' }; const { keys } = await fetch(keysUrl).then((r) => r.json()); // à mettre en cache / archiver const key = keys.find((k) => k.kid === proof.proof_kid); if (!key) return { valid: false, reason: 'clé inconnue' }; const ok = verify(null, bytes, createPublicKey(key.public_key_pem), Buffer.from(proof.proof_signature, 'base64')); return { valid: ok, reason: ok ? null : 'signature invalide' }; } Archivez la clé publique avec vos preuves : la vérification reste possible même si sign-hub disparaît. Le webhook request.signed porte aussi proof_signature et proof_kid ; le dossier complet s'obtient par GET /api/requests/{id}/proof.json. ## Webhook Signatory Hub appelle votre `callback_url` en POST JSON. À défaut de `callback_url` sur la demande, le `webhook_url` configuré sur l'app est utilisé. Événements : request.created, request.viewed, request.signed, request.expired, request.cancelled, request.reminded, signer.signed, ping. Par défaut une app ne reçoit que `request.signed` ; les autres se souscrivent dans la console (onglet Apps → Intégration). ### Corps reçu { "event": "request.signed", "delivery_id": "uuid", // dédupliquez là-dessus : un rejeu réémet le même "created_at": "2026-09-04T10:12:33.000Z", "id": "uuid", // id de la demande "token": "uuid", "status": "signed", "client_ref": "quote_8f21c", // votre référence "title": "Devis DC-2026-0142", "signer_name": "Marie Durand", "signer_email": "marie.durand@example.com", "signed_at": "2026-09-04T10:12:31.000Z", "signer_ip": "82.64.x.x", "expires_at": "2026-10-04T09:00:00.000Z", "signature_data": "data:image/png;base64,...", // uniquement sur request.signed "document_hash": "…", // uniquement sur request.signed "proof_hash": "…", // uniquement sur request.signed "proof_signature": "…", // sceau Ed25519 du dossier (request.signed) "proof_kid": "…" // clé à utiliser pour le vérifier } ### En-têtes X-SignHub-Event nom de l'événement X-SignHub-Delivery id de livraison (déduplication) X-SignHub-Timestamp époque UNIX en secondes X-SignHub-Signature sha256=HMAC_SHA256(corps, secret) (historique) X-SignHub-Signature-V2 sha256=HMAC_SHA256(timestamp + "." + corps, secret) (recommandé) Le `secret` est le *webhook secret* de votre app, visible dans la console (onglet Apps → bouton Intégration → Webhook secret). Il est distinct de la clé API. ### Vérification — Node / Express import crypto from 'crypto'; import express from 'express'; const app = express(); const SECRET = process.env.SIGN_HUB_WEBHOOK_SECRET; // Le corps BRUT est indispensable : JSON.stringify(req.body) ne redonne pas // toujours les mêmes octets, et le HMAC ne correspondrait plus. app.post('/api/webhooks/sign-hub', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); const ts = req.get('X-SignHub-Timestamp') || ''; // Rejeu : au-delà de 5 minutes, on refuse. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); const expected = 'sha256=' + crypto .createHmac('sha256', SECRET).update(ts + '.' + raw).digest('hex'); const got = req.get('X-SignHub-Signature-V2') || ''; // Comparaison à temps constant, jamais ===. const a = Buffer.from(expected), b = Buffer.from(got); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401); const evt = JSON.parse(raw); if (evt.event === 'request.signed') { // Idempotent : ce webhook peut arriver deux fois (reprise, rejeu manuel). // markQuoteSigned(evt.client_ref, evt.signed_at, evt.signature_data); } res.sendStatus(200); // répondre vite : au-delà de 10 s, c'est un échec }); ### Vérification — PHP $raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_SIGNHUB_TIMESTAMP'] ?? ''; if (abs(time() - (int)$ts) > 300) { http_response_code(400); exit; } $expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, getenv('SIGN_HUB_WEBHOOK_SECRET')); if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNHUB_SIGNATURE_V2'] ?? '')) { http_response_code(401); exit; } $evt = json_decode($raw, true); http_response_code(200); ### Reprises Toute réponse autre que 2xx (ou un dépassement de 10 s) déclenche une nouvelle tentative, avec backoff exponentiel : 30 s, 1 min, 2 min, 4 min, 8 min (5 essais par défaut). Les livraisons sont journalisées et rejouables depuis la console. Un même événement peut donc arriver plusieurs fois : **votre traitement doit être idempotent**, en vous appuyant sur `delivery_id` ou sur `client_ref`. Votre URL doit être publique et en HTTPS. Les adresses privées (127.0.0.1, 10.x, 192.168.x, .local…) sont refusées : une garde anti-SSRF les bloque à la création de la demande comme à l'envoi. ## Page de signature (endpoints publics) GET /api/sign/{token} → { request: { title, summary, company, status, signed, otp_required, consent_text, lock_signer_name, has_document, document_name, redirect_url } } GET /api/sign/{token}/document → le PDF joint (original, puis signé une fois la signature faite) POST /api/sign/{token}/otp → envoie le code à 6 chiffres, { ok, sent_to, expires_in } POST /api/sign/{token} → { name, signature, consent: true, code? } → { ok: true } GET /api/sign/{token}/proof → justificatif PDF pour le signataire (après signature) `signature` est un PNG en data-URL (`data:image/png;base64,…`), 400 ko maximum. `consent` doit valoir true (case cochée). `name` doit égaler `signer_name` si la demande est verrouillée. `code` est exigé dès qu'un `signer_email` est associé. Vous n'avez normalement pas à appeler ces endpoints : la page hébergée par Signatory Hub (`public_url`) s'en charge. ## Codes d'erreur Toutes les erreurs ont la même forme : { "success": false, "error": "message" } 400 requête invalide (champ hors bornes — le message nomme le champ —, URL de callback non publique, signature malformée…) 429 trop de requêtes (Retry-After), ou quota mensuel atteint (code "quota_exceeded" + objet quota + upgrade_url : ne pas retenter en boucle) 401 code de vérification incorrect 403 clé API invalide ou app désactivée 404 ressource introuvable — y compris une demande appartenant à une autre app 409 conflit d'état : déjà signé, déjà annulé, ou preuve demandée avant signature 410 lien ou code expiré 428 un code de vérification doit d'abord être demandé 429 quota dépassé — respectez l'en-tête Retry-After 5xx incident côté service : réessayez avec backoff ## Types TypeScript type SignHubSummary = { intro?: string; items?: { label: string; value: string }[]; total?: string; client_ref?: string; }; type CreateRequestInput = { title: string; summary?: SignHubSummary; signer_name?: string; signer_email?: string; callback_url?: string; expires_in_days?: number; }; type CreatedRequest = { id: string; token: string; public_url: string }; type SignHubStatus = 'pending' | 'viewed' | 'signed' | 'expired' | 'cancelled'; type SignHubWebhook = { event: 'request.created' | 'request.viewed' | 'request.signed' | 'request.expired' | 'request.cancelled' | 'request.reminded' | 'signer.signed' | 'ping'; delivery_id: string; created_at: string; id: string; token: string; status: SignHubStatus; client_ref: string | null; title: string | null; signer_name: string | null; signer_email: string | null; signed_at: string | null; signer_ip: string | null; expires_at: string | null; signature_data?: string | null; document_hash?: string | null; proof_hash?: string | null; proof_signature?: string | null; proof_kid?: string | null; }; /** proof.json : le dossier figé + son enveloppe (sceau, horodatage). */ export type SignHubProof = { request_id: string; app_name: string | null; title: string | null; document_hash: string; signer_name: string | null; signer_email: string | null; signed_at: string | null; signer_ip: string | null; signer_user_agent: string | null; otp_verified_at: string | null; signature_sha256: string | null; proof_hash: string; proof_signature: string | null; proof_kid: string | null; seal_alg: 'Ed25519' | null; tsa: { url: string; time: string; token: string } | null; verify_url: string; keys_url: string; }; ## Client minimal export class SignHub { constructor( private baseUrl = 'https://signatoryhub.com', private apiKey = process.env.SIGN_HUB_API_KEY!, ) {} private async call(path: string, init: RequestInit = {}): Promise { const res = await fetch(this.baseUrl + path, { ...init, headers: { 'Content-Type': 'application/json', 'x-api-key': this.apiKey, ...init.headers }, }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json.error ?? `Signatory Hub ${res.status}`); return json as T; } create(input: CreateRequestInput) { return this.call('/api/requests', { method: 'POST', body: JSON.stringify(input), }); } get(id: string) { return this.call<{ status: SignHubStatus }>(`/api/requests/${id}`); } /** Configuration de l'app (webhook_url, événements souscrits). */ config() { return this.call<{ app: Record }>('/api/app'); } /** Règle le webhook sans passer par la console — utilisable au déploiement. */ configure(webhook_url: string, webhook_events = ['request.signed']) { return this.call<{ app: Record }>('/api/app', { method: 'PATCH', body: JSON.stringify({ webhook_url, webhook_events }), }); } /** Ping : renvoie le résultat réel de la livraison (code HTTP obtenu). */ testWebhook(url?: string) { return this.call<{ ok: boolean; delivery: { status: string; status_code?: number; error?: string } }>( '/api/app/webhook-test', { method: 'POST', body: JSON.stringify(url ? { url } : {}) }, ); } cancel(id: string) { return this.call<{ ok: true }>(`/api/requests/${id}/cancel`, { method: 'POST' }); } async proof(id: string): Promise { const res = await fetch(`${this.baseUrl}/api/requests/${id}/proof`, { headers: { 'x-api-key': this.apiKey }, }); if (!res.ok) throw new Error(`Preuve indisponible (${res.status})`); return res.arrayBuffer(); } } ## Recette d'intégration 1. Créer l'app dans la console Signatory Hub. Copier la **clé API** et le **webhook secret** (onglet Apps → Intégration) dans vos variables d'environnement. 2. Renseigner les **hôtes de callback autorisés** de l'app (console) : seule cette liste pourra recevoir vos webhooks. 3. Configurer le webhook depuis votre script de déploiement plutôt qu'à la main : `PATCH /api/app` avec `webhook_url` et `webhook_events`. 4. Vérifier la chaîne : `POST /api/app/webhook-test`. Tant que la réponse ne contient pas `"ok": true`, l'intégration n'est pas prête — `delivery.error` et `delivery.status_code` disent ce qui bloque. 5. Sur l'action « faire signer » : appeler `POST /api/requests` avec `client_ref` = votre identifiant de document, puis envoyer `public_url` au signataire par vos propres moyens. 6. Exposer l'endpoint webhook, vérifier le HMAC, traiter de façon idempotente. 7. À la réception de `request.signed`, récupérer le PDF de preuve et l'archiver avec votre document. ## Pièges fréquents - Recalculer le HMAC sur un corps re-sérialisé : utiliser le **corps brut**. - Comparer les signatures avec `===` : utiliser une comparaison à temps constant. - Traiter le webhook de façon non idempotente : il peut arriver plusieurs fois. - Oublier `signer_email` : la vérification par code ne s'applique alors pas et la seule preuve devient la possession du lien. - Utiliser `token` comme clé de corrélation : préférer `client_ref` ou `id`. - Exposer la clé API côté navigateur : elle n'a rien à y faire. - Répondre lentement au webhook : au-delà de 10 s la livraison est comptée en échec.