1. How it works
You host nothing and install nothing. The whole flow has four steps:
- 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. - They send a request (name, e-mail, phone, requirements, photos). Fittle stores it under your key.
- 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).
- 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.
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.
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.
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: new → seen → contacted → quoted → won / 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.
stateis 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-Deliveryignored, 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)
| Code | Meaning 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.