Handbuch

Fittle in ein Portal integrieren – Schritt für Schritt.

Für Entwickler von Portalen, Ketten und eigenen Systemen: Konfigurator einbetten, Anfragen per Webhook empfangen, mit der API arbeiten und dem Handwerker die 3D-Vorschau zeigen. Mit Beispielen in Node.js, PHP und curl. Eine Basisintegration dauert etwa eine Stunde.

1. So funktioniert es

Sie hosten nichts und installieren nichts. Der ganze Ablauf hat vier Schritte:

  1. Der Konfigurator läuft auf Ihrer Website in einem iframe (ein <script>). Der Kunde entwirft Küche, Schrank, Wohnwand, Möbel oder Bad in 3D.
  2. Er sendet eine Anfrage (Name, E-Mail, Telefon, Wünsche, Fotos). Fittle speichert sie unter Ihrem Schlüssel.
  3. Sie erhalten sie dreifach: per E-Mail (mit 3D-Link), per Webhook (signierter POST an Ihren Server – sofort) und über die API (jederzeit später, inklusive Fotos).
  4. Sie geben sie an Ihre Handwerker weiter – jede Anfrage hat einen 3D-Vorschau-Link, der ohne Konto öffnet.

Was Sie brauchen: ein Tischler-Konto auf app.getfittle.com mit Abrechnung pro Anfrage (Portale und Ketten zahlen kein Paket, sondern pro empfangene Anfrage) – nach der Umstellung erscheint im Konto die Karte Integration mit Webhook und API-Schlüssel. Schreiben Sie uns, wir stellen um.

Ohne Abrechnung pro Anfrage gibt es keine Integrationskarte, und API-Aufrufe liefern 403 integration_off. Das reine Einbetten des Konfigurators (Schritt 1) funktioniert auch im normalen Paket.

Schritt 1 – Konfigurator einbetten (5 Minuten)

Der einfachste Weg: ein Skript auf der Seite, auf der der Konfigurator erscheinen soll. Den Schlüssel FITT-… finden Sie im Konto; er ist an Ihre Domains gebunden (im Konto einstellen) und kann daher nicht von einer fremden Seite missbraucht werden.

<div id="configurator-de"></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. Die Sprache des Konfigurators ist die Standardsprache Ihres Kontos; der Kunde kann sie in der Kopfzeile umschalten.

Portal mit angemeldeten Nutzern: statt data-target rufen Sie HNL.mount() auf – Sie füllen Name, E-Mail und Telefon im Anfrageformular vor und erfahren, wann eine Anfrage gesendet wurde:

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

Ereignisse HNL.on: ready {product}, quote {id, product, design}, design (Antwort auf HNL.getDesign(cb)), height {height}. HNL.prefill({…}) kann auch später aufgerufen werden.

Tipp. Das Ereignis quote kommt im Browser des Kunden – gut für eine Dankesseite oder Weiterleitung. Für die serverseitige Verarbeitung nutzen Sie den Webhook (Schritt 2), der ist signiert.

Schritt 2 – Webhook (20 Minuten)

Im Konto → Integration tragen Sie die Webhook-URL (https) und ein Secret ein (beliebige lange Zeichenkette, z. B. 32 Zufallszeichen). Bei jeder Anfrage senden wir:

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

Zustellregeln:

  • Antworten Sie mit 2xx innerhalb von 8 Sekunden. Verarbeiten Sie erst nach der Antwort (Queue, Worker) – sonst droht ein Timeout.
  • Bei Fehler oder Timeout wiederholen wir nach 10 s, 1 min und 5 min, dann geben wir auf – die Anfrage können Sie jederzeit über die API holen. Weiterleitungen (3xx) folgen wir nicht.
  • Gleiche X-Furniconf-Delivery = gleiche Zustellung. Speichern Sie sie und ignorieren Sie Duplikate (Idempotenz).
  • Kundenfotos sind nicht im Webhook (nur photoCount) – laden Sie sie über die API (Schritt 3).

Signatur prüfen

Die Signatur ist HMAC-SHA256 mit Ihrem Secret über die Zeichenkette timestamp + "." + Roh-Body. Vergleichen Sie in konstanter Zeit:

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

Dasselbe 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: Die Schaltfläche Test-Webhook senden im Konto schickt ein Ereignis webhook.test mit denselben Headern; das Ergebnis (HTTP-Status, Versuche) sehen Sie im Konto und wir im Log.

Häufigster Fehler: Die Signatur stimmt nicht, weil das Framework den Body erst geparst und neu serialisiert hat (andere Leerzeichen, Schlüsselreihenfolge). Signiert wird der Roh-Body – in Express express.raw() vor express.json(), in PHP php://input, in Django request.body, in Laravel $request->getContent().

Schritt 3 – API (15 Minuten)

Im Konto → Integration → API-Schlüssel erzeugen. Der Schlüssel fak_… wird nur einmal angezeigt – legen Sie ihn in den Server-Secrets ab, nie im Frontend. Jeder Aufruf trägt Authorization: Bearer fak_…, Bodies sind JSON, Limit 600 Aufrufe pro 10 Minuten.

Neue Anfragen (neueste zuerst; Fotos nur als Anzahl):

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

Eine Anfrage mit Fotoliste und Download eines Fotos:

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

Statuswechsel – damit Sie und wir sehen, wo die Anfrage steht:

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-Vorschau-Link für den Handwerker (ohne Konto, signiert, gültig für die angegebene Anzahl Tage):

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

Ohne Webhook: regelmäßiges Abholen (z. B. alle 5 Minuten):

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

Anfragestatus: newseencontactedquotedwon / lost. Weitere Pfade: GET /api/portal/me (Ihr Profil und Einstellungen), POST /api/portal/webhook/test.

Fehlerantworten haben die Form { "error": "…", "reason": "…", "reqId": "…" } – nennen Sie bei einer Problemmeldung die reqId.

Schritt 4 – 3D-Vorschau für den Handwerker und im Portal

Jede Anfrage hat einen Link aus view-link: Der Handwerker öffnet ihn im Browser (auch mobil) und sieht genau, was der Kunde entworfen hat – drehen, Maße, Türen öffnen, Rundgang, ohne Anmeldung. Senden Sie ihn per E-Mail oder SMS oder zeigen Sie ihn im Anfragedetail Ihres Portals:

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

Der Link ist signiert und zeitlich begrenzt (days, Standard 30). Nach Ablauf erzeugen Sie einen neuen – die Anfrage bleibt gespeichert.

Schritt 5 – Was im Design-JSON steckt

design hat zwei Ebenen: eine lesbare Beschreibung für Menschen und state für Maschinen:

{
  "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
}
  • Lesbare Werte (Form, Maße, Module mit Inhalt, Dekore mit Codes, Geräte, Raum mit Öffnungen) sind immer in der Standardsprache Ihres Kontos – egal, in welcher Sprache der Kunde geklickt hat.
  • state ist der rohe Konfiguratorzustand (Codes, keine Texte). Damit lässt sich der Entwurf jederzeit wieder in 3D laden; nicht verändern.
  • room.cart (nur im Preismodus): Warenkorb mit Produktcodes, Mengen und Summen, wenn der Kunde Ihre 3D-Objekte oder Fliesen im Raum platziert hat.
  • Das Format ist stabil – neue Felder kommen nur hinzu, bestehende werden nie umbenannt.

Abrechnung und Limits

  • Sie zahlen pro empfangene Anfrage den vereinbarten Preis ohne MwSt.; die Monatsrechnung kommt per E-Mail (PDF), die Anfrageübersicht steht im Konto.
  • API: 600 Aufrufe / 10 Minuten pro Schlüssel; Webhook: 8 s für die Antwort, 4 Versuche.
  • Die Kundenanfrage ist größenbegrenzt (Entwurf bis 350 kB, Notiz 4 000 Zeichen, Fotos bis 2 MB) – Größeres lehnt der Browser schon vor dem Senden ab.

Checkliste vor dem Start

  • Die Domains Ihrer Website stehen im Konto (Schlüsselsperre) und der Konfigurator lädt auf der Live-Seite.
  • Webhook: Signatur über den Roh-Body geprüft, 2xx innerhalb 8 s, Duplikate nach X-Furniconf-Delivery ignoriert, Test bestanden.
  • Der API-Schlüssel liegt in den Server-Secrets; bei einem Leck erzeugen Sie im Konto einen neuen (der alte wird sofort ungültig).
  • Sie melden Anfragestatus zurück (status) – sie sind auch im Konto sichtbar.
  • Der Handwerker bekommt den view-link, kein JSON.
  • Die Abrechnung pro Anfrage ist eingerichtet (sonst fehlt die Integrationskarte).

Fehlercodes (reason)

CodeBedeutung und Lösung
api_key (401)Der Schlüssel fak_… ist ungültig oder es wurde ein neuer erzeugt. Prüfen Sie den Header Authorization.
integration_off (403)Das Konto hat keine Abrechnung pro Anfrage – Webhook/API sind inaktiv. Schreiben Sie uns.
suspended (403)Das Konto ist gesperrt.
rate_limit (429)Zu viele Aufrufe (600 / 10 min). Langsamer, nutzen Sie since.
license, quota, bad_email… (/api/quote)Der Konfigurator hat die Anfrage abgelehnt: ungültige Lizenz/Domain (license), Monatslimit überschritten (quota), ungültige E-Mail/Telefon/Länge (bad_email, bad_phone, too_long), Entwurf über 350 kB (too_big).

Support

Chat direkt im Konto (Karte Support) oder E-Mail; bei technischen Problemen die reqId aus der Antwort und X-Furniconf-Delivery aus dem Webhook beilegen. Vollständige Referenz: PORTAL.md.