Guía

Integrar Fittle en un portal – paso a paso.

Para desarrolladores de portales, cadenas y sistemas propios: incrustar el configurador, recibir solicitudes por webhook, trabajar con la API y mostrar la vista 3D al carpintero. Con ejemplos en Node.js, PHP y curl. Una integración básica lleva alrededor de una hora.

1. Cómo funciona

No aloja ni instala nada. Todo el flujo tiene cuatro pasos:

  1. El configurador se ejecuta en su web en un iframe (un solo <script>). El cliente diseña en 3D una cocina, un armario, un mueble de salón, mobiliario o un baño.
  2. Envía una solicitud (nombre, correo, teléfono, requisitos, fotos). Fittle la guarda bajo su clave.
  3. Usted la recibe de tres formas: por correo (con enlace 3D), por webhook (POST firmado a su servidor – al instante) y mediante la API (en cualquier momento, fotos incluidas).
  4. La reparte a sus carpinteros – cada solicitud tiene un enlace de vista 3D que se abre sin cuenta.

Qué necesita: una cuenta de carpintero en app.getfittle.com con facturación por solicitud (portales y cadenas no pagan un plan sino cada solicitud recibida) – tras el cambio aparece en la cuenta la tarjeta Integración con webhook y clave API. Escríbanos y lo activamos.

Sin facturación por solicitud no hay tarjeta Integración y las llamadas a la API devuelven 403 integration_off. La simple inserción del configurador (paso 1) funciona también con un plan normal.

Paso 1 – Incrustar el configurador (5 minutos)

La forma más sencilla: un script en la página donde debe aparecer el configurador. La clave FITT-… está en su cuenta; está vinculada a sus dominios (se definen en la cuenta), así que no puede usarse desde otro sitio.

<div id="configurator-es"></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. El idioma del configurador es el idioma predeterminado de su cuenta; el cliente puede cambiarlo en la cabecera.

Portal con usuarios identificados: en lugar de data-target llame a HNL.mount() – rellena nombre, correo y teléfono en el formulario de solicitud y se entera de cuándo se envió una solicitud:

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

Eventos HNL.on: ready {product}, quote {id, product, design}, design (respuesta a HNL.getDesign(cb)), height {height}. HNL.prefill({…}) también puede llamarse más tarde.

Consejo. El evento quote llega en el navegador del cliente – útil para una página de agradecimiento o una redirección. Para procesar en el servidor use el webhook (paso 2), que va firmado.

Paso 2 – Webhook (20 minutos)

En la cuenta → Integración defina la URL del webhook (https) y un secreto (cualquier cadena larga, p. ej. 32 caracteres aleatorios). En cada solicitud enviamos:

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

Reglas de entrega:

  • Responda 2xx en menos de 8 segundos. Procese después de responder (cola, worker) – de lo contrario hay riesgo de timeout.
  • Ante error o timeout reintentamos a los 10 s, 1 min y 5 min, y luego desistimos – siempre puede descargar la solicitud por la API. No seguimos redirecciones (3xx).
  • Mismo X-Furniconf-Delivery = misma entrega. Guárdelo e ignore duplicados (idempotencia).
  • Las fotos del cliente no van en el webhook (solo photoCount) – descárguelas por la API (paso 3).

Verificación de la firma

La firma es HMAC-SHA256 con su secreto sobre la cadena timestamp + "." + cuerpo sin procesar. Compare en tiempo constante:

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

Lo mismo en 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']
}

Prueba: el botón Enviar webhook de prueba de la cuenta envía un evento webhook.test con las mismas cabeceras; el resultado (estado HTTP, intentos) se ve en su cuenta y en nuestro registro.

Error más frecuente: la firma no coincide porque el framework analizó y volvió a serializar el cuerpo (espacios, orden de claves distintos). Se firma el cuerpo sin procesar – en Express express.raw() antes de express.json(), en PHP php://input, en Django request.body, en Laravel $request->getContent().

Paso 3 – API (15 minutos)

En la cuenta → Integración → Generar clave API. La clave fak_… se muestra una sola vez – guárdela en los secretos del servidor, nunca en el frontend. Cada llamada lleva Authorization: Bearer fak_…, los cuerpos son JSON, límite 600 llamadas por 10 minutos.

Solicitudes nuevas (las más recientes primero; fotos solo como número):

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

Una solicitud con su lista de fotos y descarga de una 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

Cambio de estado – para que usted y nosotros veamos en qué punto está la solicitud:

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

Enlace de vista 3D para el carpintero (sin cuenta, firmado, válido los días indicados):

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

Sin webhook: consulta periódica (p. ej. cada 5 minutos):

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

Estados de la solicitud: newseencontactedquotedwon / lost. Otras rutas: GET /api/portal/me (su perfil y ajustes), POST /api/portal/webhook/test.

Las respuestas de error tienen la forma { "error": "…", "reason": "…", "reqId": "…" } – indique el reqId al reportar un problema.

Paso 4 – Vista 3D para el carpintero y en el portal

Cada solicitud tiene un enlace de view-link: el carpintero lo abre en el navegador (también en el móvil) y ve exactamente lo que diseñó el cliente – rotar, cotas, abrir puertas, recorrido, sin iniciar sesión. Envíelo por correo o SMS, o muéstrelo en el detalle de la solicitud de su 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>

El enlace está firmado y tiene caducidad (days, 30 por defecto). Cuando expire genere uno nuevo – la solicitud sigue guardada.

Paso 5 – Qué contiene el JSON del diseño

design tiene dos capas: una descripción legible para personas y state para la máquina:

{
  "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
}
  • Los valores legibles (forma, medidas, módulos y su contenido, decoraciones con códigos, electrodomésticos, habitación con huecos) están siempre en el idioma predeterminado de su cuenta – sea cual sea el idioma que usó el cliente.
  • state es el estado bruto del configurador (códigos, no textos). Permite volver a cargar el diseño en 3D en cualquier momento; no lo modifique.
  • room.cart (solo en modo precios): carrito con códigos de producto, cantidades e importes si el cliente colocó sus objetos 3D o azulejos en la habitación.
  • El formato es estable – solo se añaden campos nuevos, los existentes nunca se renombran.

Facturación y límites

  • Paga por cada solicitud recibida el precio acordado sin IVA; la factura mensual llega por correo (PDF), el resumen de solicitudes está en su cuenta.
  • API: 600 llamadas / 10 minutos por clave; webhook: 8 s para responder, 4 intentos.
  • La solicitud del cliente tiene límite de tamaño (diseño hasta 350 kB, nota 4.000 caracteres, fotos hasta 2 MB) – lo que exceda lo rechaza el navegador antes de enviar.

Lista de comprobación antes del lanzamiento

  • Los dominios de su web están en la cuenta (bloqueo de clave) y el configurador carga en la página real.
  • Webhook: firma verificada sobre el cuerpo sin procesar, 2xx en menos de 8 s, duplicados por X-Furniconf-Delivery ignorados, prueba superada.
  • La clave API está en los secretos del servidor; si se filtra, genere una nueva en la cuenta (la antigua deja de valer al instante).
  • Devuelve los estados de las solicitudes (status) – también se ven en la cuenta.
  • El carpintero recibe el view-link, no el JSON.
  • La facturación por solicitud está configurada (si no, falta la tarjeta Integración).

Códigos de error (reason)

CódigoSignificado y solución
api_key (401)La clave fak_… no es válida o se generó una nueva. Revise la cabecera Authorization.
integration_off (403)La cuenta no tiene facturación por solicitud – webhook/API inactivos. Escríbanos.
suspended (403)La cuenta está suspendida.
rate_limit (429)Demasiadas llamadas (600 / 10 min). Reduzca el ritmo, use since.
license, quota, bad_email… (/api/quote)El configurador rechazó la solicitud: licencia/dominio no válido (license), tope mensual superado (quota), correo/teléfono/longitud no válidos (bad_email, bad_phone, too_long), diseño de más de 350 kB (too_big).

Soporte

Chat directamente en la cuenta (tarjeta Soporte) o correo; ante un problema técnico adjunte el reqId de la respuesta y el X-Furniconf-Delivery del webhook. Referencia completa: PORTAL.md.