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

  1. Ouvrez la console et saisissez votre email : un lien de connexion vous est envoyé. Aucun mot de passe.
  2. 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.
  3. Copiez la clé publique pk_live_…. Le secret isk_live_… s’affiche une seule fois : il ne sert qu’à l’identité vérifiée, côté serveur.
  4. 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.
La clé publique n’est pas un secret : elle identifie votre Project et n’autorise que les actions d’un utilisateur ordinaire, depuis les origines et applications que vous avez déclarées.

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) // optionnel

iOS — 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) // optionnel

Flutter (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}"
end

Java / 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 + "." + sig

Comportement du widget

ÉcranCe que voit l’utilisateur
IdéesRecherche, 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.
ProposerTitre (suggestions d’idées similaires dès trois caractères, votables sur place), détails optionnels, publication. L’auteur vote et suit automatiquement.
DétailStatut, vote, suivi, historique public avec les notes de l’équipe, nouveautés liées, commentaires paginés.
RoadmapPlanifié, en cours, livré ; trois colonnes sur tablette et ordinateur, un sélecteur de colonne sur téléphone.
NouveautésReleases publiées et idées livrées liées ; un lien d’email ouvre directement la bonne entrée.
PréférencesEmails 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

Emails

Deux canaux distincts, volontairement séparés.

CanalExpéditeurContenu
Compte développeurKeyfeedLien de connexion à la console. Rien d’autre.
Utilisateurs de votre applicationVous (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/ready

Questions : créez un compte et ouvrez une idée dans le Project « Keyfeed » : nous utilisons notre propre widget.