Opas

Fittlein integrointi portaaliin – vaihe vaiheelta.

Portaalien, ketjujen ja omien järjestelmien kehittäjille: upota konfiguraattori, vastaanota tarjouspyynnöt webhookilla, käytä API:a ja näytä 3D-esikatselu puusepälle. Esimerkit Node.js:llä, PHP:llä ja curlilla. Perusintegrointi vie noin tunnin.

1. Näin se toimii

Et isännöi etkä asenna mitään. Koko kulku on neljä vaihetta:

  1. Konfiguraattori toimii sivustollasi iframessa (yksi <script>). Asiakas suunnittelee keittiön, kaapin, olohuoneen seinän, huonekalun tai kylpyhuoneen 3D:nä.
  2. Hän lähettää tarjouspyynnön (nimi, sähköposti, puhelin, toiveet, kuvat). Fittle tallentaa sen avaimesi alle.
  3. Saat sen kolmella tavalla: sähköpostitse (3D-linkillä), webhookilla (allekirjoitettu POST palvelimellesi – heti) ja API:n kautta (milloin tahansa myöhemmin, kuvineen).
  4. Jaat sen puusepillesi – jokaisella tarjouspyynnöllä on 3D-esikatselulinkki, joka avautuu ilman tiliä.

Mitä tarvitset: puuseppätilin osoitteessa app.getfittle.com tarjouspyyntökohtaisella laskutuksella (portaalit ja ketjut eivät maksa pakettia vaan jokaisesta vastaanotetusta tarjouspyynnöstä) – vaihdon jälkeen tilille ilmestyy kortti Integraatio webhookilla ja API-avaimella. Ota yhteyttä, niin vaihdamme.

Ilman tarjouspyyntökohtaista laskutusta Integraatio-korttia ei ole ja API-kutsut palauttavat 403 integration_off. Pelkkä konfiguraattorin upottaminen (vaihe 1) toimii myös tavallisella paketilla.

Vaihe 1 – Konfiguraattorin upottaminen (5 minuuttia)

Yksinkertaisin tapa: yksi skripti sivulle, jolla konfiguraattorin pitää olla. Avain FITT-… löytyy tililtä; se on sidottu verkkotunnuksiisi (asetetaan tilillä), joten sitä ei voi väärinkäyttää toiselta sivulta.

<div id="configurator-fi"></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. Konfiguraattorin kieli on tilisi oletuskieli; asiakas voi vaihtaa sen ylätunnisteesta.

Portaali kirjautuneilla käyttäjillä: kutsu data-targetin sijaan HNL.mount() – esitäytät nimen, sähköpostin ja puhelimen tarjouspyyntölomakkeeseen ja saat tiedon, kun tarjouspyyntö on lähetetty:

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

Tapahtumat HNL.on: ready {product}, quote {id, product, design}, design (vastaus kutsuun HNL.getDesign(cb)), height {height}. HNL.prefill({…}) voi kutsua myös myöhemmin.

Vinkki. Tapahtuma quote tulee asiakkaan selaimessa – sopii kiitossivuun tai uudelleenohjaukseen. Palvelinpuolen käsittelyyn käytä webhookia (vaihe 2), joka on allekirjoitettu.

Vaihe 2 – Webhook (20 minuuttia)

Tilillä → Integraatio aseta Webhook-URL (https) ja salaisuus (mikä tahansa pitkä merkkijono, esim. 32 satunnaista merkkiä). Jokaisesta tarjouspyynnöstä lähetämme:

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

Toimitussäännöt:

  • Vastaa 2xx 8 sekunnissa. Käsittele vasta vastauksen jälkeen (jono, worker) – muuten uhkaa aikakatkaisu.
  • Virheen tai aikakatkaisun sattuessa yritämme uudelleen 10 s, 1 min ja 5 min kuluttua, sitten luovutamme – tarjouspyynnön voi aina hakea API:n kautta. Uudelleenohjauksia (3xx) ei seurata.
  • Sama X-Furniconf-Delivery = sama toimitus. Tallenna se ja ohita kaksoiskappaleet (idempotenssi).
  • Asiakkaan kuvat eivät ole webhookissa (vain photoCount) – lataa ne API:n kautta (vaihe 3).

Allekirjoituksen tarkistus

Allekirjoitus on HMAC-SHA256 salaisuudellasi merkkijonosta timestamp + "." + raaka runko. Vertaa vakioajassa:

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

Sama PHP:llä:

<?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']
}

Testi: tilin painike Lähetä testi-webhook lähettää webhook.test-tapahtuman samoilla otsakkeilla; tuloksen (HTTP-tila, yritykset) näet tilillä ja me lokissa.

Yleisin virhe: allekirjoitus ei täsmää, koska kehys jäsensi ja sarjallisti rungon uudelleen (eri välilyönnit, avainjärjestys). Allekirjoitetaan raaka runko – Expressissä express.raw() ennen express.json(), PHP:ssä php://input, Djangossa request.body, Laravelissa $request->getContent().

Vaihe 3 – API (15 minuuttia)

Tilillä → Integraatio → Luo API-avain. Avain fak_… näytetään vain kerran – tallenna se palvelimen salaisuuksiin, ei koskaan frontendiin. Jokaisessa kutsussa on Authorization: Bearer fak_…, rungot ovat JSONia, raja 600 kutsua / 10 minuuttia.

Uudet tarjouspyynnöt (uusin ensin; kuvat vain lukumääränä):

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

Yksi tarjouspyyntö kuvalistoineen ja kuvan lataus:

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

Tilan muutos – jotta sinä ja me näemme, missä tarjouspyyntö on:

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-esikatselulinkki puusepälle (ilman tiliä, allekirjoitettu, voimassa annetun päivämäärän):

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

Ilman webhookia: säännöllinen haku (esim. 5 minuutin välein):

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

Tarjouspyynnön tilat: newseencontactedquotedwon / lost. Muut polut: GET /api/portal/me (profiilisi ja asetukset), POST /api/portal/webhook/test.

Virhevastaukset ovat muotoa { "error": "…", "reason": "…", "reqId": "…" } – mainitse reqId, kun ilmoitat ongelmasta.

Vaihe 4 – 3D-esikatselu puusepälle ja portaalissa

Jokaisella tarjouspyynnöllä on linkki view-linkistä: puuseppä avaa sen selaimessa (myös mobiilissa) ja näkee tarkalleen sen, mitä asiakas suunnitteli – kääntö, mitat, ovien avaus, kävely, ilman kirjautumista. Lähetä se sähköpostilla tai tekstiviestillä, tai näytä se portaalisi tarjouspyynnön tiedoissa:

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

Linkki on allekirjoitettu ja määräaikainen (days, oletus 30). Vanhentuessa luot uuden – tarjouspyyntö pysyy tallessa.

Vaihe 5 – Mitä suunnitelman JSON sisältää

designillä on kaksi kerrosta: luettava kuvaus ihmisille ja state koneelle:

{
  "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
}
  • Luettavat arvot (muoto, mitat, moduulit sisältöineen, dekorit koodeineen, laitteet, huone aukkoineen) ovat aina tilisi oletuskielellä – riippumatta siitä, millä kielellä asiakas klikkasi.
  • state on konfiguraattorin raaka tila (koodeja, ei tekstejä). Sen ansiosta suunnitelman voi ladata milloin tahansa takaisin 3D:hen; älä muuta sitä.
  • room.cart (vain hintatilassa): ostoskori tuotekoodeineen, määrineen ja summineen, jos asiakas sijoitti huoneeseen 3D-objektejasi tai laattojasi.
  • Muoto on vakaa – uusia kenttiä vain lisätään, olemassa olevia ei koskaan nimetä uudelleen.

Laskutus ja rajat

  • Maksat sovitun hinnan ilman ALV:tä jokaisesta vastaanotetusta tarjouspyynnöstä; kuukausilasku tulee sähköpostitse (PDF), tarjouspyyntöraportti on tilillä.
  • API: 600 kutsua / 10 minuuttia avainta kohden; webhook: 8 s vastausaikaa, 4 yritystä.
  • Asiakkaan tarjouspyynnöllä on kokorajat (suunnitelma enintään 350 kt, huomautus 4 000 merkkiä, kuvat enintään 2 Mt) – suuremmat selain hylkää jo ennen lähetystä.

Tarkistuslista ennen käyttöönottoa

  • Sivustosi verkkotunnukset ovat tilillä (avainlukko) ja konfiguraattori latautuu tuotantosivulla.
  • Webhook: allekirjoitus tarkistettu raa’asta rungosta, 2xx 8 s:ssa, kaksoiskappaleet X-Furniconf-Deliveryn mukaan ohitetaan, testi läpäisty.
  • API-avain on palvelimen salaisuuksissa; vuodon sattuessa luot tilillä uuden (vanha lakkaa heti).
  • Lähetät tarjouspyyntöjen tilat takaisin (status) – ne näkyvät myös tilillä.
  • Puuseppä saa view-linkin, ei JSONia.
  • Tarjouspyyntökohtainen laskutus on asetettu (muuten Integraatio-kortti puuttuu).

Virhekoodit (reason)

KoodiMerkitys ja ratkaisu
api_key (401)Avain fak_… on virheellinen tai uusi on luotu. Tarkista otsake Authorization.
integration_off (403)Tilillä ei ole tarjouspyyntökohtaista laskutusta – webhook/API eivät ole käytössä. Ota yhteyttä.
suspended (403)Tili on jäädytetty.
rate_limit (429)Liikaa kutsuja (600 / 10 min). Hidasta, käytä since-parametria.
license, quota, bad_email… (/api/quote)Konfiguraattori hylkäsi tarjouspyynnön: virheellinen lisenssi/verkkotunnus (license), kuukausikatto ylitetty (quota), virheellinen sähköposti/puhelin/pituus (bad_email, bad_phone, too_long), suunnitelma yli 350 kt (too_big).

Tuki

Chat suoraan tilillä (Tuki-kortti) tai sähköposti; teknisessä ongelmassa liitä vastauksen reqId ja webhookin X-Furniconf-Delivery. Täydellinen viite: PORTAL.md.