Handleiding

Fittle in een portaal integreren – stap voor stap.

Voor ontwikkelaars van portalen, ketens en eigen systemen: de configurator insluiten, aanvragen via webhook ontvangen, met de API werken en de 3D-weergave aan de vakman tonen. Met voorbeelden in Node.js, PHP en curl. Een basisintegratie kost ongeveer een uur.

1. Hoe het werkt

U host niets en installeert niets. De hele flow bestaat uit vier stappen:

  1. De configurator draait op uw site in een iframe (één <script>). De klant ontwerpt in 3D een keuken, kast, wandmeubel, meubel of badkamer.
  2. Hij stuurt een aanvraag (naam, e-mail, telefoon, wensen, foto’s). Fittle slaat die op onder uw sleutel.
  3. U ontvangt die op drie manieren: per e-mail (met 3D-link), via webhook (ondertekende POST naar uw server – direct) en via de API (op elk later moment, inclusief foto’s).
  4. U verdeelt die onder uw vakmensen – elke aanvraag heeft een 3D-weergavelink die zonder account opent.

Wat u nodig hebt: een meubelmakersaccount op app.getfittle.com met facturatie per aanvraag (portalen en ketens betalen geen pakket maar per ontvangen aanvraag) – na de omschakeling verschijnt in het account de kaart Integratie met webhook en API-sleutel. Schrijf ons, wij schakelen u om.

Zonder facturatie per aanvraag is er geen kaart Integratie en geven API-aanroepen 403 integration_off terug. Alleen de configurator insluiten (stap 1) werkt ook met een gewoon pakket.

Stap 1 – De configurator insluiten (5 minuten)

De eenvoudigste manier: één script op de pagina waar de configurator moet staan. De sleutel FITT-… vindt u in uw account; hij is gekoppeld aan uw domeinen (in te stellen in het account), dus vanaf een andere site niet te misbruiken.

<div id="configurator-nl"></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. De taal van de configurator is de standaardtaal van uw account; de klant kan die in de kop omschakelen.

Portaal met ingelogde gebruikers: roep in plaats van data-target HNL.mount() aan – u vult naam, e-mail en telefoon in het aanvraagformulier vooraf in en krijgt bericht wanneer een aanvraag is verzonden:

<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>

Gebeurtenissen HNL.on: ready {product}, quote {id, product, design}, design (antwoord op HNL.getDesign(cb)), height {height}. HNL.prefill({…}) kan ook later worden aangeroepen.

Tip. De gebeurtenis quote komt in de browser van de klant – handig voor een bedankpagina of doorverwijzing. Voor verwerking op de server gebruikt u de webhook (stap 2), die is ondertekend.

Stap 2 – Webhook (20 minuten)

In het account → Integratie stelt u de Webhook-URL (https) en een geheim in (een willekeurige lange tekenreeks, bv. 32 willekeurige tekens). Bij elke aanvraag sturen we:

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": { … } }
    }
  }
}

Bezorgregels:

  • Antwoord 2xx binnen 8 seconden. Verwerk pas na het antwoord (wachtrij, worker) – anders dreigt een time-out.
  • Bij een fout of time-out proberen we opnieuw na 10 s, 1 min en 5 min, daarna geven we op – u kunt de aanvraag altijd via de API ophalen. Omleidingen (3xx) volgen we niet.
  • Dezelfde X-Furniconf-Delivery = dezelfde bezorging. Sla die op en negeer duplicaten (idempotentie).
  • Klantfoto’s zitten niet in de webhook (alleen photoCount) – download ze via de API (stap 3).

Handtekening controleren

De handtekening is HMAC-SHA256 met uw geheim over de tekenreeks timestamp + "." + ruwe body. Vergelijk in constante tijd:

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

Hetzelfde in 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: de knop Testwebhook sturen in het account stuurt een gebeurtenis webhook.test met dezelfde headers; het resultaat (HTTP-status, pogingen) ziet u in het account en wij in het logboek.

Meest voorkomende fout: de handtekening klopt niet omdat het framework de body eerst heeft geparset en opnieuw geserialiseerd (andere spaties, sleutelvolgorde). De ruwe body wordt ondertekend – in Express express.raw() vóór express.json(), in PHP php://input, in Django request.body, in Laravel $request->getContent().

Stap 3 – API (15 minuten)

In het account → Integratie → API-sleutel genereren. De sleutel fak_… wordt maar één keer getoond – bewaar hem in de servergeheimen, nooit in de frontend. Elke aanroep draagt Authorization: Bearer fak_…, bodies zijn JSON, limiet 600 aanroepen per 10 minuten.

Nieuwe aanvragen (nieuwste eerst; foto’s alleen als aantal):

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": { … } }
] }

Eén aanvraag met fotolijst en het downloaden van een foto:

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

Statuswijziging – zodat u en wij zien waar de aanvraag staat:

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", … } }

3D-weergavelink voor de vakman (zonder account, ondertekend, geldig voor het opgegeven aantal dagen):

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" }

Zonder webhook: periodiek ophalen (bv. elke 5 minuten):

# 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_…"

Aanvraagstatussen: newseencontactedquotedwon / lost. Andere paden: GET /api/portal/me (uw profiel en instellingen), POST /api/portal/webhook/test.

Foutantwoorden hebben de vorm { "error": "…", "reason": "…", "reqId": "…" } – vermeld de reqId als u een probleem meldt.

Stap 4 – 3D-weergave voor de vakman en in het portaal

Elke aanvraag heeft een link uit view-link: de vakman opent hem in de browser (ook mobiel) en ziet precies wat de klant heeft ontworpen – draaien, maten, deuren openen, rondlopen, zonder inloggen. Stuur hem per e-mail of sms, of toon hem in het aanvraagdetail van uw portaal:

<!-- 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>

De link is ondertekend en beperkt geldig (days, standaard 30). Na verloop genereert u een nieuwe – de aanvraag blijft opgeslagen.

Stap 5 – Wat zit er in de ontwerp-JSON

design heeft twee lagen: een leesbare beschrijving voor mensen en state voor de 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
}
  • Leesbare waarden (vorm, afmetingen, modules met inhoud, decors met codes, apparaten, ruimte met openingen) zijn altijd in de standaardtaal van uw account – ongeacht de taal die de klant gebruikte.
  • state is de ruwe configuratortoestand (codes, geen teksten). Daarmee kan het ontwerp op elk moment weer in 3D worden geladen; niet wijzigen.
  • room.cart (alleen in prijsmodus): winkelwagen met productcodes, aantallen en bedragen als de klant uw 3D-objecten of tegels in de ruimte heeft geplaatst.
  • Het formaat is stabiel – nieuwe velden worden alleen toegevoegd, bestaande worden nooit hernoemd.

Facturatie en limieten

  • U betaalt per ontvangen aanvraag de afgesproken prijs excl. btw; de maandfactuur komt per e-mail (PDF), het aanvraagoverzicht staat in uw account.
  • API: 600 aanroepen / 10 minuten per sleutel; webhook: 8 s om te antwoorden, 4 pogingen.
  • De aanvraag van de klant is beperkt in omvang (ontwerp tot 350 kB, opmerking 4.000 tekens, foto’s tot 2 MB) – grotere weigert de browser al vóór het verzenden.

Checklist vóór livegang

  • De domeinen van uw site staan in het account (sleutelvergrendeling) en de configurator laadt op de live pagina.
  • Webhook: handtekening gecontroleerd over de ruwe body, 2xx binnen 8 s, duplicaten op X-Furniconf-Delivery genegeerd, test geslaagd.
  • De API-sleutel staat in de servergeheimen; bij een lek genereert u in het account een nieuwe (de oude vervalt direct).
  • U stuurt aanvraagstatussen terug (status) – ze zijn ook in het account zichtbaar.
  • De vakman krijgt de view-link, geen JSON.
  • Facturatie per aanvraag is ingesteld (anders ontbreekt de kaart Integratie).

Foutcodes (reason)

CodeBetekenis en oplossing
api_key (401)De sleutel fak_… is ongeldig of er is een nieuwe gegenereerd. Controleer de header Authorization.
integration_off (403)Het account heeft geen facturatie per aanvraag – webhook/API zijn inactief. Schrijf ons.
suspended (403)Het account is geschorst.
rate_limit (429)Te veel aanroepen (600 / 10 min). Vertraag, gebruik since.
license, quota, bad_email… (/api/quote)De configurator heeft de aanvraag geweigerd: ongeldige licentie/domein (license), maandlimiet overschreden (quota), ongeldige e-mail/telefoon/lengte (bad_email, bad_phone, too_long), ontwerp groter dan 350 kB (too_big).

Ondersteuning

Chat rechtstreeks in het account (kaart Ondersteuning) of e-mail; voeg bij een technisch probleem de reqId uit het antwoord en de X-Furniconf-Delivery uit de webhook toe. Volledige referentie: PORTAL.md.