Ghid

Integrarea Fittle într-un portal – pas cu pas.

Pentru dezvoltatorii de portaluri, lanțuri și sisteme proprii: încorporați configuratorul, primiți cererile prin webhook, lucrați cu API-ul și arătați previzualizarea 3D meșterului. Cu exemple în Node.js, PHP și curl. O integrare de bază durează aproximativ o oră.

1. Cum funcționează

Nu găzduiți și nu instalați nimic. Întregul flux are patru pași:

  1. Configuratorul rulează pe site-ul dvs. într-un iframe (un singur <script>). Clientul proiectează în 3D o bucătărie, un dulap, o mobilă de living, mobilier sau o baie.
  2. Trimite o cerere (nume, e-mail, telefon, cerințe, fotografii). Fittle o salvează sub cheia dvs.
  3. O primiți în trei moduri: prin e-mail (cu link 3D), prin webhook (POST semnat către serverul dvs. – imediat) și prin API (oricând mai târziu, inclusiv fotografiile).
  4. O distribuiți meșterilor – fiecare cerere are un link de previzualizare 3D care se deschide fără cont.

De ce aveți nevoie: un cont de tâmplar pe app.getfittle.com cu facturare per cerere (portalurile și lanțurile nu plătesc un abonament, ci fiecare cerere primită) – după comutare, în cont apare cardul Integrare cu webhook și cheie API. Scrieți-ne și vă comutăm.

Fără facturare per cerere nu există cardul Integrare, iar apelurile API returnează 403 integration_off. Simpla încorporare a configuratorului (pasul 1) funcționează și cu un abonament obișnuit.

Pasul 1 – Încorporarea configuratorului (5 minute)

Cel mai simplu mod: un script pe pagina unde trebuie să apară configuratorul. Cheia FITT-… se află în cont; este legată de domeniile dvs. (setate în cont), deci nu poate fi folosită de pe alt site.

<div id="configurator-ro"></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. Limba configuratorului este limba implicită a contului dvs.; clientul o poate schimba din antet.

Portal cu utilizatori autentificați: în loc de data-target apelați HNL.mount() – precompletați numele, e-mailul și telefonul în formularul de cerere și aflați când a fost trimisă o cerere:

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

Evenimente HNL.on: ready {product}, quote {id, product, design}, design (răspuns la HNL.getDesign(cb)), height {height}. HNL.prefill({…}) poate fi apelat și mai târziu.

Sfat. Evenimentul quote sosește în browserul clientului – util pentru o pagină de mulțumire sau o redirecționare. Pentru procesarea pe server folosiți webhook-ul (pasul 2), care este semnat.

Pasul 2 – Webhook (20 de minute)

În cont → Integrare setați URL-ul webhook (https) și un secret (orice șir lung, de ex. 32 de caractere aleatorii). La fiecare cerere trimitem:

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

Reguli de livrare:

  • Răspundeți 2xx în maximum 8 secunde. Procesați după răspuns (coadă, worker) – altfel riscați un timeout.
  • La eroare sau timeout reîncercăm după 10 s, 1 min și 5 min, apoi renunțăm – cererea poate fi oricând descărcată prin API. Redirecționările (3xx) nu sunt urmate.
  • Același X-Furniconf-Delivery = aceeași livrare. Salvați-l și ignorați duplicatele (idempotență).
  • Fotografiile clientului nu sunt în webhook (doar photoCount) – descărcați-le prin API (pasul 3).

Verificarea semnăturii

Semnătura este HMAC-SHA256 cu secretul dvs. peste șirul timestamp + "." + corp brut. Comparați în timp 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);

Același lucru în 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: butonul Trimite webhook de test din cont trimite un eveniment webhook.test cu aceleași antete; rezultatul (stare HTTP, încercări) se vede în cont și în jurnalul nostru.

Cea mai frecventă greșeală: semnătura nu se potrivește pentru că framework-ul a parsat și reserializat corpul (alte spații, ordinea cheilor). Se semnează corpul brut – în Express express.raw() înainte de express.json(), în PHP php://input, în Django request.body, în Laravel $request->getContent().

Pasul 3 – API (15 minute)

În cont → Integrare → Generează cheie API. Cheia fak_… este afișată o singură dată – păstrați-o în secretele serverului, niciodată în frontend. Fiecare apel poartă Authorization: Bearer fak_…, corpurile sunt JSON, limită 600 de apeluri la 10 minute.

Cereri noi (cele mai recente primele; fotografiile doar ca număr):

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

O cerere cu lista fotografiilor și descărcarea unei fotografii:

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

Schimbarea stării – ca dvs. și noi să vedem unde este cererea:

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

Link de previzualizare 3D pentru meșter (fără cont, semnat, valabil numărul de zile indicat):

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

Fără webhook: interogare periodică (de ex. la fiecare 5 minute):

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

Stările cererii: newseencontactedquotedwon / lost. Alte căi: GET /api/portal/me (profilul și setările dvs.), POST /api/portal/webhook/test.

Răspunsurile de eroare au forma { "error": "…", "reason": "…", "reqId": "…" } – menționați reqId când raportați o problemă.

Pasul 4 – Previzualizare 3D pentru meșter și în portal

Fiecare cerere are un link din view-link: meșterul îl deschide în browser (și pe mobil) și vede exact ce a proiectat clientul – rotire, cote, deschiderea ușilor, plimbare, fără autentificare. Trimiteți-l prin e-mail sau SMS, ori afișați-l în detaliul cererii din portalul dvs.:

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

Linkul este semnat și limitat în timp (days, implicit 30). După expirare generați unul nou – cererea rămâne salvată.

Pasul 5 – Ce conține JSON-ul proiectului

design are două straturi: o descriere lizibilă pentru oameni și state pentru mașină:

{
  "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
}
  • Valorile lizibile (formă, dimensiuni, module și conținutul lor, decoruri cu coduri, electrocasnice, cameră cu goluri) sunt întotdeauna în limba implicită a contului dvs. – indiferent de limba folosită de client.
  • state este starea brută a configuratorului (coduri, nu texte). Datorită ei proiectul poate fi reîncărcat oricând în 3D; nu o modificați.
  • room.cart (doar în modul prețuri): coș cu coduri de produs, cantități și sume, dacă clientul a plasat în cameră obiectele 3D sau plăcile dvs.
  • Formatul este stabil – câmpurile noi se adaugă doar, cele existente nu se redenumesc niciodată.

Facturare și limite

  • Plătiți pentru fiecare cerere primită prețul convenit fără TVA; factura lunară vine prin e-mail (PDF), situația cererilor este în cont.
  • API: 600 de apeluri / 10 minute per cheie; webhook: 8 s pentru răspuns, 4 încercări.
  • Cererea clientului are limite de dimensiune (proiect până la 350 kB, notă 4 000 de caractere, fotografii până la 2 MB) – mai mult browserul refuză înainte de trimitere.

Listă de verificare înainte de lansare

  • Domeniile site-ului dvs. sunt în cont (blocarea cheii) și configuratorul se încarcă pe pagina live.
  • Webhook: semnătură verificată pe corpul brut, 2xx în 8 s, duplicatele după X-Furniconf-Delivery ignorate, test trecut.
  • Cheia API este în secretele serverului; în caz de scurgere generați una nouă în cont (cea veche încetează imediat).
  • Trimiteți înapoi stările cererilor (status) – se văd și în cont.
  • Meșterul primește view-link, nu JSON.
  • Facturarea per cerere este configurată (altfel lipsește cardul Integrare).

Coduri de eroare (reason)

CodSemnificație și rezolvare
api_key (401)Cheia fak_… este invalidă sau a fost generată una nouă. Verificați antetul Authorization.
integration_off (403)Contul nu are facturare per cerere – webhook/API sunt inactive. Scrieți-ne.
suspended (403)Contul este suspendat.
rate_limit (429)Prea multe apeluri (600 / 10 min). Încetiniți, folosiți since.
license, quota, bad_email… (/api/quote)Configuratorul a refuzat cererea: licență/domeniu invalid (license), plafon lunar depășit (quota), e-mail/telefon/lungime invalide (bad_email, bad_phone, too_long), proiect peste 350 kB (too_big).

Asistență

Chat direct în cont (cardul Asistență) sau e-mail; la o problemă tehnică atașați reqId din răspuns și X-Furniconf-Delivery din webhook. Referință completă: PORTAL.md.