Guide

Intégrer Fittle dans un portail – pas à pas.

Pour les développeurs de portails, d’enseignes et de systèmes sur mesure : intégrer le configurateur, recevoir les demandes par webhook, utiliser l’API et montrer l’aperçu 3D à l’artisan. Avec des exemples en Node.js, PHP et curl. Une intégration de base prend environ une heure.

1. Comment ça marche

Vous n’hébergez rien et n’installez rien. Le flux complet tient en quatre étapes :

  1. Le configurateur tourne sur votre site dans un iframe (un seul <script>). Le client conçoit une cuisine, une armoire, un meuble TV, du mobilier ou une salle de bain en 3D.
  2. Il envoie une demande (nom, e-mail, téléphone, exigences, photos). Fittle l’enregistre sous votre clé.
  3. Vous la recevez de trois façons : par e-mail (avec lien 3D), par webhook (POST signé vers votre serveur – immédiatement) et via l’API (à tout moment, photos comprises).
  4. Vous la transmettez à vos artisans – chaque demande a un lien d’aperçu 3D qui s’ouvre sans compte.

Ce qu’il vous faut : un compte menuisier sur app.getfittle.com avec facturation à la demande (les portails et enseignes ne paient pas un forfait mais chaque demande reçue) – après le passage, la carte Intégration avec le webhook et la clé API apparaît dans votre compte. Écrivez-nous, nous faisons le changement.

Sans facturation à la demande, il n’y a pas de carte Intégration et les appels API renvoient 403 integration_off. L’intégration du configurateur seule (étape 1) fonctionne aussi avec un forfait classique.

Étape 1 – Intégrer le configurateur (5 minutes)

Le plus simple : un script sur la page où le configurateur doit apparaître. La clé FITT-… est dans votre compte ; elle est liée à vos domaines (à définir dans le compte), donc inutilisable depuis un autre site.

<div id="configurator-fr"></div>
<script src="https://app.getfittle.com/embed.js"
        data-key="FITT-XXXX-XXXX-XXXX"
        data-product="kitchen"
        data-target="#configurator"></script>

data-product : kitchen · wardrobe · living · furniture · bathroom. La langue du configurateur est la langue par défaut de votre compte ; le client peut la changer dans l’en-tête.

Portail avec utilisateurs connectés : au lieu de data-target, appelez HNL.mount() – vous préremplissez nom, e-mail et téléphone dans le formulaire de demande et êtes informé quand une demande est envoyée :

<script src="https://app.getfittle.com/embed.js" data-key="FITT-XXXX-XXXX-XXXX"></script>
<script>
  HNL.mount('#configurator', {
    product: 'wardrobe',                 // kitchen | wardrobe | living | furniture | bathroom
    fill: true,                          // fill the parent's height (default 820 px)
    prefill: {                           // logged-in user of your portal (editable in the form)
      name: 'Jane Doe', email: 'jane@example.com', phone: '+421 900 000 000'
    }
  });
  HNL.on('ready', function (e) { console.log('configurator running:', e.product); });
  HNL.on('quote', function (e) {
    // request sent – e.id is the request id (DM…), e.design the design JSON
    location.href = '/thank-you?request=' + encodeURIComponent(e.id);
  });
</script>

Événements HNL.on : ready {product}, quote {id, product, design}, design (réponse à HNL.getDesign(cb)), height {height}. HNL.prefill({…}) peut aussi être appelé plus tard.

Astuce. L’événement quote arrive dans le navigateur du client – utile pour une page de remerciement ou une redirection. Pour le traitement côté serveur, utilisez le webhook (étape 2), qui est signé.

Étape 2 – Webhook (20 minutes)

Dans votre compte → Intégration, définissez l’URL du webhook (https) et un secret (n’importe quelle chaîne longue, p. ex. 32 caractères aléatoires). À chaque demande, nous envoyons :

POST https://portal.example.com/furniconf/webhook
Content-Type: application/json
X-Furniconf-Event: quote.created
X-Furniconf-Delivery: WMTZ7Q2K9A1B
X-Furniconf-Timestamp: 1789300000
X-Furniconf-Signature: sha256=3f1a…9c

{
  "id": "WMTZ7Q2K9A1B",
  "event": "quote.created",
  "createdAt": "2026-09-16T09:12:33.000Z",
  "shopKey": "FITT-XXXX-XXXX-XXXX",
  "data": {
    "design": {
      "id": "DMU3VCTB63BB7",
      "product": "kitchen",
      "status": "new",
      "createdAt": "2026-09-16T09:12:33.000Z",
      "customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "+421 900 000 000",
                    "note": "Handles in black, please.", "photoCount": 2 },
      "design": { "type": "kitchen", "layout": "…", "dims": { … }, "runs": [ … ], "room": { … }, "state": { … } }
    }
  }
}

Règles de livraison :

  • Répondez 2xx en moins de 8 secondes. Traitez après avoir répondu (file, worker) – sinon risque de timeout.
  • En cas d’erreur ou de timeout, nous réessayons après 10 s, 1 min et 5 min, puis abandonnons – vous pouvez toujours récupérer la demande via l’API. Les redirections (3xx) ne sont pas suivies.
  • Même X-Furniconf-Delivery = même livraison. Stockez-le et ignorez les doublons (idempotence).
  • Les photos du client ne sont pas dans le webhook (seulement photoCount) – téléchargez-les via l’API (étape 3).

Vérification de la signature

La signature est un HMAC-SHA256 avec votre secret sur la chaîne timestamp + "." + corps brut. Comparez en temps constant :

// Node.js / Express – the signature is computed over the RAW body, so read it before any JSON parser
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.FURNICONF_WEBHOOK_SECRET;

app.post('/furniconf/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ts  = req.header('X-Furniconf-Timestamp') || '';
  const got = req.header('X-Furniconf-Signature') || '';
  const want = 'sha256=' + crypto.createHmac('sha256', SECRET).update(ts + '.' + req.body).digest('hex');
  if (got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
    return res.status(401).end();                       // not from Fittle
  }
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();   // replay protection (5 min)

  const evt = JSON.parse(req.body);
  res.status(200).end();                                // answer first (within 8 s) …
  if (evt.event === 'quote.created') {
    const d = evt.data.design;
    // … then process: idempotency by delivery id, save, assign to a craftsman
    saveRequest({ deliveryId: evt.id, requestId: d.id, product: d.product, customer: d.customer, design: d.design });
  }
});
app.listen(3000);

La même chose en PHP :

<?php  // PHP – same check with hash_hmac + hash_equals
$secret = getenv('FURNICONF_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');
$ts     = $_SERVER['HTTP_X_FURNICONF_TIMESTAMP'] ?? '';
$got    = $_SERVER['HTTP_X_FURNICONF_SIGNATURE'] ?? '';
$want   = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($want, $got) || abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }

http_response_code(200);
$evt = json_decode($raw, true);
if ($evt['event'] === 'quote.created') {
  $d = $evt['data']['design'];
  // $evt['id'] = delivery id (idempotency), $d['id'] = request id, $d['customer'], $d['design']
}

Test : le bouton Envoyer un webhook de test dans votre compte envoie un événement webhook.test avec les mêmes en-têtes ; le résultat (statut HTTP, tentatives) est visible dans votre compte et dans notre journal.

Erreur la plus fréquente : la signature ne correspond pas parce que le framework a analysé puis resérialisé le corps (espaces, ordre des clés différents). C’est le corps brut qui est signé – dans Express express.raw() avant express.json(), en PHP php://input, dans Django request.body, dans Laravel $request->getContent().

Étape 3 – API (15 minutes)

Dans votre compte → Intégration → Générer une clé API. La clé fak_… n’est affichée qu’une fois – stockez-la dans les secrets du serveur, jamais dans le frontend. Chaque appel porte Authorization: Bearer fak_…, les corps sont en JSON, limite 600 appels par 10 minutes.

Nouvelles demandes (les plus récentes d’abord ; photos en nombre seulement) :

curl -s "https://app.getfittle.com/api/portal/designs?status=new&limit=50" \
  -H "Authorization: Bearer fak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

{ "designs": [
    { "id": "DMU3VCTB63BB7", "product": "kitchen", "status": "new", "createdAt": "2026-09-16T09:12:33.000Z",
      "customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "+421 900 000 000", "note": "…", "photoCount": 2 },
      "design": { … } }
] }

Une demande avec sa liste de photos, et téléchargement d’une photo :

curl -s "https://app.getfittle.com/api/portal/designs/DMU3VCTB63BB7?photos=1" \
  -H "Authorization: Bearer fak_…"

{ "design": { "id": "DMU3VCTB63BB7", …,
    "customer": { …, "photoCount": 2,
      "photos": [ { "n": 1, "name": "kitchen-now.jpg", "type": "image/jpeg", "size": 812345,
                    "url": "/api/portal/designs/DMU3VCTB63BB7/photos/1" }, … ] } } }

# photo (binary) – the API key is required here as well
curl -s "https://app.getfittle.com/api/portal/designs/DMU3VCTB63BB7/photos/1" \
  -H "Authorization: Bearer fak_…" -o kitchen-now.jpg

Changement de statut – pour que vous et nous voyions où en est la demande :

curl -s -X POST "https://app.getfittle.com/api/portal/designs/DMU3VCTB63BB7/status" \
  -H "Authorization: Bearer fak_…" -H "Content-Type: application/json" \
  -d '{ "status": "contacted" }'

{ "ok": true, "design": { "id": "DMU3VCTB63BB7", "status": "contacted", … } }

Lien d’aperçu 3D pour l’artisan (sans compte, signé, valable le nombre de jours indiqué) :

curl -s -X POST "https://app.getfittle.com/api/portal/designs/DMU3VCTB63BB7/view-link" \
  -H "Authorization: Bearer fak_…" -H "Content-Type: application/json" \
  -d '{ "days": 30 }'

{ "ok": true,
  "url": "https://app.getfittle.com/c/kitchen?k=FITT-XXXX-XXXX-XXXX&load=DMU3VCTB63BB7&vt=eyJ0…&view=1" }

Sans webhook : interrogation périodique (p. ex. toutes les 5 minutes) :

# polling instead of a webhook: everything since the last check
curl -s "https://app.getfittle.com/api/portal/designs?since=2026-09-16T00:00:00Z" -H "Authorization: Bearer fak_…"

Statuts d’une demande : newseencontactedquotedwon / lost. Autres chemins : GET /api/portal/me (votre profil et paramètres), POST /api/portal/webhook/test.

Les réponses d’erreur ont la forme { "error": "…", "reason": "…", "reqId": "…" } – indiquez le reqId quand vous signalez un problème.

Étape 4 – Aperçu 3D pour l’artisan et dans votre portail

Chaque demande a un lien issu de view-link : l’artisan l’ouvre dans un navigateur (mobile aussi) et voit exactement ce que le client a conçu – rotation, cotes, ouverture des portes, visite, sans connexion. Envoyez-le par e-mail ou SMS, ou affichez-le dans le détail de la demande de votre portail :

<!-- the request inside your portal: a 3D preview in an iframe (URL from view-link) -->
<iframe src="https://app.getfittle.com/c/kitchen?k=FITT-…&load=DMU3VCTB63BB7&vt=eyJ0…&view=1"
        style="width:100%;height:720px;border:0;border-radius:12px" allow="xr-spatial-tracking"></iframe>

Le lien est signé et limité dans le temps (days, 30 par défaut). Une fois expiré, générez-en un nouveau – la demande reste enregistrée.

Étape 5 – Contenu du JSON du projet

design a deux couches : une description lisible pour les humains et state pour la machine :

{
  "type": "kitchen",                     // kitchen | wardrobe | living | furniture | bathroom
  "layout": "Rohová (L)",                // readable values – in the shop's default language
  "dims":  { "baseHeight": 72, "wallHeight": 72, "totalHeight": 218, … },
  "runs":  [ { "id": "A", "length": 365, "columns": [ { "width": 80, "base": "Drez", "wall": "Otvorená polica", … } ] } ],
  "decors": { "base": "Dekor 3017", "baseCode": "D3017", … },
  "appliances": { "fridge": 1, "oven": 1, "hob": 1, "sink": 1, … },
  "room":  { "width": 460, "depth": 360, "height": 270, "openings": [ … ], "cart": { … } },
  "state": { … }                         // raw configurator state (codes) – for loading back into 3D
}
  • Les valeurs lisibles (forme, dimensions, modules et leur contenu, décors avec codes, appareils, pièce avec ouvertures) sont toujours dans la langue par défaut de votre compte – quelle que soit la langue utilisée par le client.
  • state est l’état brut du configurateur (codes, pas de textes). Il permet de recharger le projet en 3D à tout moment ; ne le modifiez pas.
  • room.cart (mode prix seulement) : panier avec codes produits, quantités et montants si le client a placé vos objets 3D ou carrelages dans la pièce.
  • Le format est stable – de nouveaux champs sont seulement ajoutés, les existants ne sont jamais renommés.

Facturation et limites

  • Vous payez par demande reçue le prix convenu HT ; la facture mensuelle arrive par e-mail (PDF), le relevé des demandes est dans votre compte.
  • API : 600 appels / 10 minutes par clé ; webhook : 8 s pour répondre, 4 tentatives.
  • La demande du client est limitée en taille (projet jusqu’à 350 Ko, note 4 000 caractères, photos jusqu’à 2 Mo) – au-delà, le navigateur refuse avant l’envoi.

Liste de contrôle avant la mise en service

  • Les domaines de votre site sont dans le compte (verrou de clé) et le configurateur se charge sur la page en production.
  • Webhook : signature vérifiée sur le corps brut, 2xx en moins de 8 s, doublons selon X-Furniconf-Delivery ignorés, test réussi.
  • La clé API est dans les secrets du serveur ; en cas de fuite, générez-en une nouvelle dans le compte (l’ancienne cesse immédiatement).
  • Vous renvoyez les statuts des demandes (status) – ils sont aussi visibles dans le compte.
  • L’artisan reçoit le view-link, pas le JSON.
  • La facturation à la demande est configurée (sinon la carte Intégration est absente).

Codes d’erreur (reason)

CodeSignification et solution
api_key (401)La clé fak_… est invalide ou une nouvelle a été générée. Vérifiez l’en-tête Authorization.
integration_off (403)Le compte n’a pas de facturation à la demande – webhook/API inactifs. Écrivez-nous.
suspended (403)Le compte est suspendu.
rate_limit (429)Trop d’appels (600 / 10 min). Ralentissez, utilisez since.
license, quota, bad_email… (/api/quote)Le configurateur a refusé la demande : licence/domaine invalide (license), plafond mensuel dépassé (quota), e-mail/téléphone/longueur invalide (bad_email, bad_phone, too_long), projet de plus de 350 Ko (too_big).

Support

Chat directement dans votre compte (carte Support) ou e-mail ; pour un problème technique, joignez le reqId de la réponse et le X-Furniconf-Delivery du webhook. Référence complète : PORTAL.md.