Guide

Integrating Fittle into a portal – step by step.

For developers of portals, chains and custom systems: embed the configurator, receive requests by webhook, work with the API and show the 3D preview to your craftsman. With examples in Node.js, PHP and curl. A basic integration takes about an hour.

1. How it works

You host nothing and install nothing. The whole flow has four steps:

  1. The configurator runs on your site in an iframe (one <script>). The customer designs a kitchen, wardrobe, living-room wall, furniture or bathroom in 3D.
  2. They send a request (name, e-mail, phone, requirements, photos). Fittle stores it under your key.
  3. You receive it three ways: by e-mail (with a 3D link), by webhook (a signed POST to your server – immediately) and through the API (any time later, photos included).
  4. You hand it to your craftsmen – every request has a 3D preview link that opens without an account.

What you need: a shop account on app.getfittle.com with per-request billing (portals and chains pay per received request instead of a plan) – once switched, the Integration card with the webhook and API key appears in your account. Contact us and we switch you.

Without per-request billing there is no Integration card and API calls return 403 integration_off. Embedding the configurator itself (step 1) works on a regular plan too.

Step 1 – Embedding the configurator (5 minutes)

The simplest way: one script on the page where the configurator should appear. The FITT-… key is in your account; it is bound to your domains (set them in the account), so it cannot be abused from another site.

<div id="configurator-en"></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. The configurator language is your account's default language; the customer can switch it in the header.

A portal with logged-in users: instead of data-target call HNL.mount() – you prefill name, e-mail and phone in the request form and get told when a request was sent:

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

HNL.on events: ready {product}, quote {id, product, design}, design (reply to HNL.getDesign(cb)), height {height}. HNL.prefill({…}) can be called later as well.

Tip. The quote event fires in the customer's browser – good for a thank-you page or a redirect. For server-side processing use the webhook (step 2), which is signed.

Step 2 – Webhook (20 minutes)

In your account → Integration set the Webhook URL (https) and a secret (any long string, e.g. 32 random characters). On every request we send:

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

Delivery rules:

  • Answer 2xx within 8 seconds. Process after answering (queue, worker) – otherwise you risk a timeout.
  • On error or timeout we retry after 10 s, 1 min and 5 min, then give up – you can always fetch the request through the API. Redirects (3xx) are not followed.
  • The same X-Furniconf-Delivery = the same delivery. Store it and ignore duplicates (idempotency).
  • Customer photos are not in the webhook (only photoCount) – download them through the API (step 3).

Signature check

The signature is HMAC-SHA256 with your secret over the string timestamp + "." + raw body. Compare in constant time:

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

The same 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: the Send test webhook button in your account sends a webhook.test event with the same headers; the result (HTTP status, attempts) is visible in your account and in our log.

Most common mistake: the signature does not match because the framework parsed and re-serialised the body (different whitespace, key order). The raw body is signed – in Express express.raw() before express.json(), in PHP php://input, in Django request.body, in Laravel $request->getContent().

Step 3 – API (15 minutes)

In your account → Integration → Generate API key. The fak_… key is shown once – store it in your server secrets, never in the frontend. Every call carries Authorization: Bearer fak_…, bodies are JSON, limit 600 calls per 10 minutes.

New requests (newest first; photos as a count only):

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

One request with its photo list, and downloading a 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

Status change – so you and we see where the request is:

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 preview link for the craftsman (no account, signed, valid for the given number of days):

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

Without a webhook: periodic polling (e.g. every 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_…"

Request statuses: newseencontactedquotedwon / lost. Other paths: GET /api/portal/me (your profile and settings), POST /api/portal/webhook/test.

Error responses look like { "error": "…", "reason": "…", "reqId": "…" } – quote the reqId when reporting a problem.

Step 4 – 3D preview for the craftsman and in your portal

Every request has a link from view-link: the craftsman opens it in a browser (mobile too) and sees exactly what the customer designed – rotate, dimensions, open doors, walk-through, no login. Send it by e-mail or SMS, or show it in the request detail of your 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>

The link is signed and time-limited (days, default 30). After it expires generate a new one – the request stays stored.

Step 5 – What is in the design JSON

design has two layers: a readable description for people and state for machines:

{
  "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
}
  • Readable values (layout, dimensions, modules and their contents, decors with codes, appliances, room with openings) are always in your account's default language – whatever language the customer used.
  • state is the raw configurator state (codes, not labels). It lets the design be loaded back into 3D at any time; do not modify it.
  • room.cart (price mode only): a cart with product codes, quantities and totals when the customer placed your 3D objects or tiles in the room.
  • The format is stable – new fields are only added, existing ones are never renamed.

Billing and limits

  • You pay the agreed price excl. VAT per received request; the monthly invoice arrives by e-mail (PDF), the request statement is in your account.
  • API: 600 calls / 10 minutes per key; webhook: 8 s to answer, 4 attempts.
  • A customer's request is size-limited (design up to 350 kB, note 4,000 characters, photos up to 2 MB) – larger ones are refused by the browser before sending.

Go-live checklist

  • Your site's domains are in the account (key lock) and the configurator loads on the live page.
  • Webhook: signature verified over the raw body, 2xx within 8 s, duplicates by X-Furniconf-Delivery ignored, test passed.
  • The API key lives in server secrets; if it leaks, generate a new one in the account (the old one stops immediately).
  • You send request statuses back (status) – they are visible in the account too.
  • The craftsman gets the view-link, not JSON.
  • Per-request billing is set up (otherwise the Integration card is missing).

Error codes (reason)

CodeMeaning and fix
api_key (401)The fak_… key is invalid or a new one was generated. Check the Authorization header.
integration_off (403)The account has no per-request billing – webhook/API are inactive. Contact us.
suspended (403)The account is suspended.
rate_limit (429)Too many calls (600 / 10 min). Slow down, use since.
license, quota, bad_email… (/api/quote)The configurator refused the request: invalid licence/domain (license), monthly cap exceeded (quota), invalid e-mail/phone/length (bad_email, bad_phone, too_long), design over 350 kB (too_big).

Support

Chat directly in your account (Support card) or e-mail; for a technical problem attach the reqId from the response and the X-Furniconf-Delivery from the webhook. Full reference: PORTAL.md.