Vejledning

Integrer Fittle i en portal – trin for trin.

Til udviklere af portaler, kæder og egne systemer: indlejr konfiguratoren, modtag forespørgsler via webhook, arbejd med API’et og vis 3D-visningen til håndværkeren. Med eksempler i Node.js, PHP og curl. En basisintegration tager cirka en time.

1. Sådan virker det

Du hoster intet og installerer intet. Hele flowet har fire trin:

  1. Konfiguratoren kører på dit website i en iframe (ét <script>). Kunden designer køkken, skab, tv-væg, møbler eller badeværelse i 3D.
  2. Kunden sender en forespørgsel (navn, e-mail, telefon, ønsker, fotos). Fittle gemmer den under din nøgle.
  3. Du modtager den på tre måder: pr. e-mail (med 3D-link), via webhook (signeret POST til din server – med det samme) og via API (når som helst senere, inklusive fotos).
  4. Du giver den videre til dine håndværkere – hver forespørgsel har et 3D-visningslink, der åbner uden konto.

Det, du har brug for: en snedkerkonto på app.getfittle.com med afregning pr. forespørgsel (portaler og kæder betaler ikke en pakke, men pr. modtaget forespørgsel) – efter omstillingen vises kortet Integration med webhook og API-nøgle i kontoen. Skriv til os, så stiller vi om.

Uden afregning pr. forespørgsel er der intet Integration-kort, og API-kald returnerer 403 integration_off. Selve indlejringen af konfiguratoren (trin 1) virker også på en almindelig pakke.

Trin 1 – Indlejring af konfiguratoren (5 minutter)

Den enkleste måde: ét script på den side, hvor konfiguratoren skal være. Nøglen FITT-… finder du i kontoen; den er bundet til dine domæner (indstilles i kontoen), så den kan ikke misbruges fra en anden side.

<div id="configurator-da"></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. Konfiguratorens sprog er din kontos standardsprog; kunden kan skifte det i toppen.

Portal med indloggede brugere: kald HNL.mount() i stedet for data-target – du udfylder navn, e-mail og telefon i forespørgselsformularen på forhånd og får besked, når en forespørgsel er sendt:

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

Hændelser HNL.on: ready {product}, quote {id, product, design}, design (svar på HNL.getDesign(cb)), height {height}. HNL.prefill({…}) kan også kaldes senere.

Tip. Hændelsen quote kommer i kundens browser – god til en takkeside eller omdirigering. Til behandling på serveren skal du bruge webhooken (trin 2), som er signeret.

Trin 2 – Webhook (20 minutter)

I kontoen → Integration angiver du Webhook-URL (https) og en hemmelighed (en vilkårlig lang streng, fx 32 tilfældige tegn). Ved hver forespørgsel sender vi:

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

Leveringsregler:

  • Svar 2xx inden for 8 sekunder. Behandl først efter svaret (kø, worker) – ellers risikerer du timeout.
  • Ved fejl eller timeout prøver vi igen efter 10 s, 1 min og 5 min, derefter giver vi op – du kan altid hente forespørgslen via API’et. Omdirigeringer (3xx) følges ikke.
  • Samme X-Furniconf-Delivery = samme levering. Gem den og ignorer dubletter (idempotens).
  • Kundens fotos er ikke i webhooken (kun photoCount) – hent dem via API’et (trin 3).

Kontrol af signatur

Signaturen er HMAC-SHA256 med din hemmelighed over strengen timestamp + "." + rå body. Sammenlign i konstant tid:

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

Det samme i 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: knappen Send test-webhook i kontoen sender en webhook.test-hændelse med de samme headers; resultatet (HTTP-status, forsøg) kan ses i kontoen og i vores log.

Hyppigste fejl: signaturen passer ikke, fordi frameworket først har parset og genserialiseret body’en (andre mellemrum, nøglerækkefølge). Det er den rå body, der signeres – i Express express.raw() før express.json(), i PHP php://input, i Django request.body, i Laravel $request->getContent().

Trin 3 – API (15 minutter)

I kontoen → Integration → Generér API-nøgle. Nøglen fak_… vises kun én gang – gem den i serverens hemmeligheder, aldrig i frontend. Hvert kald bærer Authorization: Bearer fak_…, bodies er JSON, grænse 600 kald pr. 10 minutter.

Nye forespørgsler (nyeste først; fotos kun som antal):

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

Én forespørgsel med fotoliste og download af et 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

Statusændring – så du og vi kan se, hvor forespørgslen er:

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-visningslink til håndværkeren (uden konto, signeret, gyldigt i det angivne antal dage):

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

Uden webhook: periodisk afhentning (fx hvert 5. minut):

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

Forespørgselsstatus: newseencontactedquotedwon / lost. Andre stier: GET /api/portal/me (din profil og indstillinger), POST /api/portal/webhook/test.

Fejlsvar har formen { "error": "…", "reason": "…", "reqId": "…" } – oplys reqId, når du rapporterer et problem.

Trin 4 – 3D-visning til håndværkeren og i portalen

Hver forespørgsel har et link fra view-link: håndværkeren åbner det i browseren (også på mobil) og ser præcis det, kunden har designet – rotation, mål, åbne låger, gåtur, uden login. Send det pr. e-mail eller sms, eller vis det i forespørgselsdetaljen i din portal:

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

Linket er signeret og tidsbegrænset (days, standard 30). Når det udløber, genererer du et nyt – forespørgslen forbliver gemt.

Trin 5 – Hvad er der i design-JSON

design har to lag: en læsbar beskrivelse til mennesker og state til maskinen:

{
  "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
}
  • Læsbare værdier (form, mål, moduler og deres indhold, dekorer med koder, hvidevarer, rum med åbninger) er altid på din kontos standardsprog – uanset hvilket sprog kunden brugte.
  • state er konfiguratorens rå tilstand (koder, ikke tekster). Den gør det muligt at indlæse designet i 3D igen når som helst; ændr den ikke.
  • room.cart (kun i pristilstand): kurv med produktkoder, antal og beløb, hvis kunden har placeret dine 3D-objekter eller fliser i rummet.
  • Formatet er stabilt – nye felter tilføjes kun, eksisterende omdøbes aldrig.

Afregning og grænser

  • Du betaler den aftalte pris ekskl. moms pr. modtaget forespørgsel; månedsfakturaen kommer pr. e-mail (PDF), oversigten over forespørgsler findes i kontoen.
  • API: 600 kald / 10 minutter pr. nøgle; webhook: 8 s til svar, 4 forsøg.
  • Kundens forespørgsel er størrelsesbegrænset (design op til 350 kB, note 4.000 tegn, fotos op til 2 MB) – større afvises af browseren allerede før afsendelse.

Tjekliste før go-live

  • Dit websites domæner er i kontoen (nøglelås), og konfiguratoren indlæses på den rigtige side.
  • Webhook: signatur kontrolleret over den rå body, 2xx inden for 8 s, dubletter efter X-Furniconf-Delivery ignoreres, test bestået.
  • API-nøglen ligger i serverens hemmeligheder; ved læk genererer du en ny i kontoen (den gamle ophører straks).
  • Du sender forespørgselsstatus tilbage (status) – de kan også ses i kontoen.
  • Håndværkeren får view-link, ikke JSON.
  • Afregning pr. forespørgsel er sat op (ellers mangler Integration-kortet).

Fejlkoder (reason)

KodeBetydning og løsning
api_key (401)Nøglen fak_… er ugyldig, eller en ny er genereret. Tjek headeren Authorization.
integration_off (403)Kontoen har ingen afregning pr. forespørgsel – webhook/API er inaktive. Skriv til os.
suspended (403)Kontoen er suspenderet.
rate_limit (429)For mange kald (600 / 10 min). Sæt tempoet ned, brug since.
license, quota, bad_email… (/api/quote)Konfiguratoren afviste forespørgslen: ugyldig licens/domæne (license), månedsloft overskredet (quota), ugyldig e-mail/telefon/længde (bad_email, bad_phone, too_long), design over 350 kB (too_big).

Support

Chat direkte i kontoen (kortet Support) eller e-mail; ved tekniske problemer vedhæft reqId fra svaret og X-Furniconf-Delivery fra webhooken. Fuld reference: PORTAL.md.