Contrat de retours
Une petite boîte de dialogue que l’on peut ouvrir sur n’importe quelle page de votre site ou produit pour vous dire ce qui ne marche pas ou ce qui manque. Collez une balise script, ou appelez l’endpoint depuis votre propre boîte de dialogue. Ce qui arrive atterrit dans un bloc du panneau, à côté de l’enquête de désinstallation et de Rate Us, et le propriétaire reçoit une notification dans sa boîte de réception.
1. Collez le widget
Une seule balise script différée, sans code inline, elle passe donc une CSP stricte :
<script src="https://extops.dev/widget.js"
data-extops-widget="wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" defer></script>
Autorisez notre origine dans script-src et connect-src. Le fichier est servi par nous, sans dépendances, et reste sous 7 Ko gzip ; ses styles passent par le CSSOM dans un shadow root, vous n’avez donc pas besoin de 'unsafe-inline' et le CSS de votre page ne peut pas déformer la boîte de dialogue.
Attributs facultatifs : data-locale, data-api, data-position="left", et data-button="false" si vous voulez l’ouvrir vous-même avec window.ExtOpsFeedback.open(). Aussi : data-button-style="icon" pour un bouton rond à icône de 48 px, data-surface="app" pour le placer au-dessus de la navigation inférieure d’une app sur téléphone, et data-color-preset pour fixer la couleur du bouton depuis la page.
Thème clair et sombre
Chaque preset de couleur est dessiné en thème clair et en thème sombre. Par défaut (data-theme="auto"), le widget suit votre page : <html data-theme>, une classe dark ou light sur <html>, ou son color-scheme. Une page sans thème propre reçoit le thème du système du visiteur. Quand votre bouton de thème modifie l’une de ces propriétés, le widget se redessine aussitôt, sans rechargement.
Pour fixer le thème, indiquez data-theme="light" ou data-theme="dark". Si votre page garde son thème là où le widget ne le voit pas, appelez window.ExtOpsFeedback.setTheme('dark') depuis votre bouton de thème, et setTheme('auto') pour revenir au suivi de la page.
2. Ou appelez l’endpoint vous-même
Pour un site qui n’autorise aucun script tiers, ou un produit qui veut le formulaire dans son propre design system. C’est le même endpoint que celui de notre widget : vous n’avez jamais à choisir entre votre interface et notre pipeline.
await fetch('https://extops.dev/api/v1/feedback/widget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_key: 'wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX',
topic: 'bug',
comment: userText,
page: location.href,
locale: navigator.language,
event_id: crypto.randomUUID(),
dwell_ms: Date.now() - openedAt,
}),
});
Lisez les sujets configurés par le propriétaire depuis GET /api/v1/feedback/widget/config?key=… pour que votre boîte de dialogue propose exactement ce que propose le panneau. Réutilisez le même event_id lors des nouvelles tentatives : le serveur déduplique dessus, un réseau instable ne transforme donc pas un message en deux.
3. La clé du widget est publique à dessein
Elle apparaît dans le code source de votre page, et c’est normal : son seul pouvoir est de créer une ligne de retour. Elle ne peut pas écrire d’événements d’installation ou de désinstallation, ne peut rien lire au-delà de la configuration de la boîte de dialogue et ne peut pas désigner un projet (project_id dans le corps n’est pas accepté). C’est pourquoi un widget reçoit sa propre clé au lieu du webhook_token de votre source, qui peut écrire des événements de métriques et ne doit jamais figurer dans le HTML. Vous pouvez réémettre la clé à tout moment ; l’ancienne cesse aussitôt d’être acceptée.
4. Ce que nous stockons et ce que nous ne stockons pas
Nous stockons le sujet, le texte, la page normalisée, la langue, un contact facultatif, l’heure, le type de retour et le widget qui l’a recueilli.
- Ni la query string ni le fragment de la page. Ils sont supprimés à la réception : ils contiennent souvent des jetons d’accès, des adresses e-mail et des termes de recherche que nous n’avons jamais demandés et que nous ne voulons pas garder 180 jours.
- Ni le pays ni l’IP de l’expéditeur. Le pays déterminé ne figure que dans l’agrégat quotidien, jamais sur un événement individuel ; l’IP n’est qu’une clé transitoire du limiteur de débit.
- Aucun contact si le widget n’en demande pas. Un extrait obsolète qui l’envoie encore ne reçoit pas d’erreur : le champ est ignoré, car le texte de confidentialité promet ce que dit la configuration actuelle du propriétaire.
La conservation est de 180 jours, la même règle que pour tous les autres types de retours.
5. Réponses et codes d’erreur
Une réponse réussie ressemble à {"status":"ok","recorded":true}. recorded: false est aussi un succès : l’envoi était un doublon, ou une heuristique anti-bot s’est déclenchée. Affichez le même remerciement dans les deux cas : un script ne doit pas apprendre quelle règle l’a arrêté, et une personne qui réessaie après un timeout ne doit pas voir d’erreur pour un message que nous avons déjà.
| HTTP | code | Quand |
|---|---|---|
| 400 | validation_error | pas de clé, pas de sujet, un sujet que le widget ne propose pas, un commentaire vide, un corps mal formé |
| 403 | origin_not_allowed | l’origine de la page ne figure pas dans la liste autorisée du widget |
| 404 | not_found | clé inconnue ou un widget désactivé : la même réponse, pour qu’une clé publique ne puisse pas sonder quels projets existent |
| 429 | rate_limited | 60/heure par clé, 10/heure par IP ; renvoie Retry-After |
| 401 | source_token_invalid | chemin côté serveur uniquement : jeton de source incorrect |
| 500 | internal_error | erreur de notre côté |
6. Sujets, types et statuts
Un sujet est un id plus un label. L’id est un slug, car il est stocké sur chaque ligne et devient la valeur de filtre dans le panneau ; le libellé est rédigé par le propriétaire. Un widget sans sujets configurés propose ceux par défaut : bug, idea, question, other.
La couleur de la boîte de dialogue est l’un des six préréglages partagés avec les pages hébergées : amber (par défaut), teal, indigo, violet, rose, slate. Une seule liste, volontairement : le widget et les pages hébergées d’un même propriétaire doivent se ressembler, et une seconde palette dériverait de la première.
Les lignes stockées ont un type : uninstall, rate_us ou widget. Le type est une ÉTIQUETTE, pas un pipeline séparé : le bloc du panneau, les notifications et la conservation sont communs. Le statut de tri du propriétaire est new, read ou spam ; marquer comme spam retire la ligne de la liste de travail et des notifications sans effacer ce qu’une personne a écrit, et la ligne peut être restaurée.
7. La protection contre les abus se fait côté serveur
Comme la clé est publique, un contrôle côté navigateur serait un conseil, pas une règle. C’est le serveur qui l’applique : la liste d’origines autorisées du widget (vide signifie toute origine, car un client peut servir un site depuis une douzaine de sous-domaines), deux limites de débit indépendantes (par clé et par IP), un champ honeypot et une durée minimale de présence, et une déduplication par (widget_id, event_id) en base plutôt qu’un contrôle préalable, car deux nouvelles tentatives simultanées passeraient toutes deux un SELECT.
Les deux limiteurs échouent en mode ouvert : si notre Redis tombe, le retour d’une personne vous parvient quand même. Pour un endpoint qui écrit un paragraphe, c’est le moindre mal. Nous n’ajoutons pas de captcha externe, qui casserait votre CSP et la confidentialité de vos visiteurs.
8. Le contrat lisible par machine
La description formelle des deux endpoints de réception, de la lecture de configuration, des enums et des codes d’erreur est le fichier OpenAPI 3.1. Il porte la même version de contrat (1.0.0).