Documentation
Tout ce qu’il faut pour intégrer Keyfeed dans une application Web, Android, iOS ou Flutter, puis répondre à vos utilisateurs depuis la console.
- Démarrer
- Intégration cliente
- Identité déclarée ou vérifiée
- Comportement du widget
- Console d’équipe
- Emails
- API
- Auto-hébergement
Démarrer
- Ouvrez la console et saisissez votre email : un lien de connexion vous est envoyé. Aucun mot de passe.
- Nommez votre application : c’est la seule saisie obligatoire. Origines Web et identifiants mobiles se déclarent quand vous publiez ; localhost et la console sont toujours acceptés.
- Copiez la clé publique
pk_live_…. Le secretisk_live_…s’affiche une seule fois : il ne sert qu’à l’identité vérifiée, côté serveur. - Cliquez « Essayer le widget maintenant » : l’aperçu en direct charge le vrai widget dans la console. Puis collez l’extrait de votre plateforme dans votre application.
Intégration cliente
Web — balise script (sans build)
<script src="https://widget.142-44-160-52.sslip.io/v1/loader.js" defer></script>
<script>
window.ProductFeedback = window.ProductFeedback || [];
ProductFeedback.push(['init', {
key: 'pk_live_votre_cle_publique',
user: { id: 'ID-STABLE-DE-VOTRE-UTILISATEUR', name: 'Ada Lovelace', email: 'ada@example.com' }
}]);
</script>Web — npm (tout framework)
import { ProductFeedback } from '@product-feedback/widget';
ProductFeedback.init({
key: 'pk_live_votre_cle_publique',
user: { id: currentUser.id, name: currentUser.name, email: currentUser.email },
locale: 'fr'
});Web — React
import { ProductFeedbackProvider } from '@product-feedback/widget/react';
<ProductFeedbackProvider publicKey="pk_live_votre_cle_publique" user={{ id: user.id, name: user.name }}>
{children}
</ProductFeedbackProvider>Android — Kotlin
// build.gradle.kts
implementation("com.productfeedback:widget:<version exacte>")
// après l'authentification de l'utilisateur
ProductFeedback.init(context, key = "pk_live_votre_cle_publique", user = FeedbackUser(id = user.id, name = user.name, email = user.email))
ProductFeedback.show(activity)
ProductFeedback.handleDeepLink(intent.data) // optionneliOS — Swift
// Package.swift : .package(url: "https://github.com/<org>/product-feedback-ios", exact: "<version>")
ProductFeedback.configure(key: "pk_live_votre_cle_publique", user: .init(id: user.id, name: user.name, email: user.email))
ProductFeedback.present(from: viewController)
ProductFeedback.handle(url: url) // optionnelFlutter (WebView)
Une WebView confinée à l’origine du widget et un canal JavaScript PFBridge. Le widget envoie PF_READY, l’application répond PF_INIT avec la clé, l’utilisateur, la langue et le thème ; PF_CLOSE ferme l’écran. Cette intégration tourne en production dans une application Quran (Android) et sert de référence.
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('PFBridge', onMessageReceived: (msg) {
final data = jsonDecode(msg.message);
if (data['type'] == 'PF_READY') sendInit(); // PF_INIT : clé, utilisateur, thème
if (data['type'] == 'PF_CLOSE') Navigator.of(context).maybePop();
})
..loadRequest(Uri.parse('https://widget.142-44-160-52.sslip.io/embed/pk_live_votre_cle_publique'));
void sendInit() => controller.runJavaScript(
'window.__pfReceive(' + jsonEncode(jsonEncode({
'type': 'PF_INIT', 'protocolVersion': 1, 'sdkVersion': 'flutter-0.1.0',
'publicKey': 'pk_live_votre_cle_publique',
'user': {'externalId': user.id, 'name': user.name, 'locale': 'fr'},
'theme': isDark ? 'dark' : 'light',
'host': {'platform': 'android', 'appId': 'com.example.app'},
})) + ');');Identité déclarée ou vérifiée
Déclarée (par défaut) : votre application transmet l’identifiant de l’utilisateur ; la plateforme le croit. Suffisant pour un usage interne ou quand l’usurpation n’a pas d’enjeu.
Vérifiée : votre serveur signe un token base64url(JSON{v, external_id, exp}) + "." + base64url(HMAC-SHA-256) avec le secret isk_, valable 24 h au plus, et le passe au SDK via identityToken. Un Project passé en vérifié refuse ensuite toute session sans token : pas de retour en arrière silencieux.
Node.js
import { createIdentityToken } from '@product-feedback/server';
// ou sans dépendance :
import { createHmac } from 'node:crypto';
function identityToken(secret, externalId, ttlSeconds = 3600) {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const body = Buffer.from(JSON.stringify({ v: 1, external_id: externalId, exp })).toString('base64url');
const sig = createHmac('sha256', secret).update(body).digest('base64url');
return body + '.' + sig;
}Python
import base64, hmac, hashlib, json, time
def identity_token(secret: str, external_id: str, ttl: int = 3600) -> str:
payload = json.dumps({"v": 1, "external_id": external_id, "exp": int(time.time()) + ttl}, separators=(",", ":"))
body = base64.urlsafe_b64encode(payload.encode()).rstrip(b"=").decode()
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()
return body + "." + base64.urlsafe_b64encode(sig).rstrip(b"=").decode()PHP
function identityToken(string $secret, string $externalId, int $ttl = 3600): string {
$b64 = fn(string $s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$body = $b64(json_encode(['v' => 1, 'external_id' => $externalId, 'exp' => time() + $ttl], JSON_UNESCAPED_SLASHES));
return $body . '.' . $b64(hash_hmac('sha256', $body, $secret, true));
}Go
func identityToken(secret, externalID string, ttl time.Duration) string {
payload, _ := json.Marshal(map[string]any{"v": 1, "external_id": externalID, "exp": time.Now().Add(ttl).Unix()})
body := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}Ruby
require 'json'; require 'base64'; require 'openssl'
def identity_token(secret, external_id, ttl = 3600)
body = Base64.urlsafe_encode64({ v: 1, external_id: external_id, exp: Time.now.to_i + ttl }.to_json, padding: false)
sig = Base64.urlsafe_encode64(OpenSSL::HMAC.digest('sha256', secret, body), padding: false)
"#{body}.#{sig}"
endJava / Kotlin (serveur)
String body = Base64.getUrlEncoder().withoutPadding().encodeToString(
("{\"v\":1,\"external_id\":\"" + externalId + "\",\"exp\":" + (Instant.now().getEpochSecond() + 3600) + "}").getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
String identityToken = body + "." + sig;Swift (serveur, CryptoKit)
let payload = try JSONSerialization.data(withJSONObject: ["v": 1, "external_id": externalId, "exp": Int(Date().timeIntervalSince1970) + 3600])
let body = payload.base64URLEncodedString()
let sig = Data(HMAC<SHA256>.authenticationCode(for: Data(body.utf8), using: SymmetricKey(data: Data(secret.utf8)))).base64URLEncodedString()
let identityToken = body + "." + sigComportement du widget
| Écran | Ce que voit l’utilisateur |
|---|---|
| Idées | Recherche, tri Populaires / Récentes, filtre par statut, liste avec vote à droite, bouton flottant « Proposer une idée ». Trois onglets au pouce : Idées, Roadmap, Nouveautés. |
| Proposer | Titre (suggestions d’idées similaires dès trois caractères, votables sur place), détails optionnels, publication. L’auteur vote et suit automatiquement. |
| Détail | Statut, vote, suivi, historique public avec les notes de l’équipe, nouveautés liées, commentaires paginés. |
| Roadmap | Planifié, en cours, livré ; trois colonnes sur tablette et ordinateur, un sélecteur de colonne sur téléphone. |
| Nouveautés | Releases publiées et idées livrées liées ; un lien d’email ouvre directement la bonne entrée. |
| Préférences | Emails de suivi, langue (fr, en). |
Thème : suit l’appareil, ou forcé par l’hôte (theme: 'light' | 'dark'). Accent : la couleur du Project. Police : celle du système hôte. Deep link : ProductFeedback.show('/roadmap'), show('/posts/<id>'), show('/releases/<id>').
Console d’équipe
- Inbox : recherche, filtres par statut, tri récent ou par votes, pagination.
- Détail : changement de statut avec note publique (les abonnés sont prévenus pour Planifié et Fermé), fusion vers une idée canonique, masquage, blocage d’un auteur.
- Releases : rédaction Markdown, liaison d’idées planifiées ou en cours, publication : elles passent en livré.
- Paramètres : origines, identifiants d’application, mode d’identité, apparence, langues, fournisseur d’emails, rotation du secret, archivage.
- Membres : Owner et Editor. Utilisateurs : export JSON et anonymisation.
Emails
Deux canaux distincts, volontairement séparés.
| Canal | Expéditeur | Contenu |
|---|---|---|
| Compte développeur | Keyfeed | Lien de connexion à la console. Rien d’autre. |
| Utilisateurs de votre application | Vous (votre clé Resend ou votre relais SMTP, votre adresse) | « Votre idée est planifiée », « fermée », « une nouveauté est disponible », chacun avec lien d’ouverture et de désabonnement. |
Tant qu’aucun fournisseur n’est configuré dans les paramètres du Project, aucune notification ne part ; les envois sont tracés comme supprimés. Un bouton « Envoyer un email de test » vérifie votre configuration.
API
L’API SDK est servie sur l’origine du widget (https://widget.142-44-160-52.sslip.io/v1/…) avec un token de session opaque ; l’API d’administration sur l’origine de la console (https://keyfeed.142-44-160-52.sslip.io/v1/admin/…) avec le cookie staff et un jeton CSRF. La description OpenAPI 3.1 est fournie avec le code (docs/generated/openapi.json, 36 chemins). Toutes les mutations acceptent une clé d’idempotence.
Auto-hébergement
Un processus Node.js (Next.js) et PostgreSQL 17. Deux variantes Docker Compose : avec Caddy embarqué (TLS automatique) ou derrière votre reverse proxy existant, sans publier de port. Les migrations s’appliquent au démarrage ; la sauvegarde est un pg_dump quotidien avec exercice de restauration fourni.
cp deploy/.env.example deploy/.env # secrets, domaines, fournisseur d'email de la plateforme
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
curl -s https://console.votre-domaine.com/health/readyQuestions : créez un compte et ouvrez une idée dans le Project « Keyfeed » : nous utilisons notre propre widget.