Guia

Integrar o Fittle num portal – passo a passo.

Para programadores de portais, cadeias e sistemas próprios: incorporar o configurador, receber pedidos por webhook, trabalhar com a API e mostrar a pré-visualização 3D ao marceneiro. Com exemplos em Node.js, PHP e curl. Uma integração básica demora cerca de uma hora.

1. Como funciona

Não aloja nem instala nada. Todo o fluxo tem quatro passos:

  1. O configurador corre no seu site num iframe (um único <script>). O cliente desenha em 3D uma cozinha, um roupeiro, um móvel de sala, mobiliário ou uma casa de banho.
  2. Envia um pedido (nome, e-mail, telefone, requisitos, fotos). O Fittle guarda-o sob a sua chave.
  3. Recebe-o de três formas: por e-mail (com ligação 3D), por webhook (POST assinado para o seu servidor – de imediato) e através da API (a qualquer momento, fotos incluídas).
  4. Distribui-o aos seus marceneiros – cada pedido tem uma ligação de pré-visualização 3D que abre sem conta.

O que precisa: uma conta de marceneiro em app.getfittle.com com faturação por pedido (portais e cadeias não pagam um plano mas cada pedido recebido) – após a mudança, aparece na conta o cartão Integração com webhook e chave API. Escreva-nos e ativamos.

Sem faturação por pedido não há cartão Integração e as chamadas à API devolvem 403 integration_off. A simples incorporação do configurador (passo 1) funciona também num plano normal.

Passo 1 – Incorporar o configurador (5 minutos)

A forma mais simples: um script na página onde o configurador deve aparecer. A chave FITT-… está na sua conta; está ligada aos seus domínios (definidos na conta), pelo que não pode ser usada a partir de outro site.

<div id="configurator-pt"></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. O idioma do configurador é o idioma predefinido da sua conta; o cliente pode mudá-lo no cabeçalho.

Portal com utilizadores autenticados: em vez de data-target chame HNL.mount() – preenche nome, e-mail e telefone no formulário do pedido e fica a saber quando um pedido foi enviado:

<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 (resposta a HNL.getDesign(cb)), height {height}. HNL.prefill({…}) também pode ser chamado mais tarde.

Dica. O evento quote chega no browser do cliente – útil para uma página de agradecimento ou um redirecionamento. Para processar no servidor use o webhook (passo 2), que é assinado.

Passo 2 – Webhook (20 minutos)

Na conta → Integração defina o URL do webhook (https) e um segredo (qualquer cadeia longa, p. ex. 32 caracteres aleatórios). Em cada pedido 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": { … } }
    }
  }
}

Regras de entrega:

  • Responda 2xx em menos de 8 segundos. Processe depois de responder (fila, worker) – caso contrário há risco de timeout.
  • Em caso de erro ou timeout tentamos de novo após 10 s, 1 min e 5 min, e depois desistimos – pode sempre descarregar o pedido pela API. Não seguimos redirecionamentos (3xx).
  • Mesmo X-Furniconf-Delivery = mesma entrega. Guarde-o e ignore duplicados (idempotência).
  • As fotos do cliente não vão no webhook (apenas photoCount) – descarregue-as pela API (passo 3).

Verificação da assinatura

A assinatura é HMAC-SHA256 com o seu segredo sobre a cadeia timestamp + "." + corpo em bruto. Compare em tempo 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);

O mesmo em 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']
}

Teste: o botão Enviar webhook de teste na conta envia um evento webhook.test com os mesmos cabeçalhos; o resultado (estado HTTP, tentativas) é visível na sua conta e no nosso registo.

Erro mais comum: a assinatura não corresponde porque a framework analisou e voltou a serializar o corpo (espaços, ordem das chaves diferentes). Assina-se o corpo em bruto – no Express express.raw() antes de express.json(), em PHP php://input, no Django request.body, no Laravel $request->getContent().

Passo 3 – API (15 minutos)

Na conta → Integração → Gerar chave API. A chave fak_… é mostrada uma única vez – guarde-a nos segredos do servidor, nunca no frontend. Cada chamada leva Authorization: Bearer fak_…, os corpos são JSON, limite de 600 chamadas por 10 minutos.

Pedidos novos (mais recentes primeiro; fotos apenas 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": { … } }
] }

Um pedido com a lista de fotos e descarga de uma 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

Mudança de estado – para que você e nós vejamos onde está o pedido:

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

Ligação de pré-visualização 3D para o marceneiro (sem conta, assinada, válida pelos dias 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" }

Sem webhook: consulta periódica (p. ex. a 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 do pedido: newseencontactedquotedwon / lost. Outros caminhos: GET /api/portal/me (o seu perfil e definições), POST /api/portal/webhook/test.

As respostas de erro têm a forma { "error": "…", "reason": "…", "reqId": "…" } – indique o reqId ao reportar um problema.

Passo 4 – Pré-visualização 3D para o marceneiro e no portal

Cada pedido tem uma ligação de view-link: o marceneiro abre-a no browser (também no telemóvel) e vê exatamente o que o cliente desenhou – rodar, cotas, abrir portas, passeio, sem iniciar sessão. Envie-a por e-mail ou SMS, ou mostre-a no detalhe do pedido do seu 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>

A ligação é assinada e tem prazo (days, 30 por predefinição). Quando expirar, gere uma nova – o pedido continua guardado.

Passo 5 – O que está no JSON do projeto

design tem duas camadas: uma descrição legível para pessoas e state para a 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
}
  • Os valores legíveis (forma, medidas, módulos e o seu conteúdo, decorações com códigos, eletrodomésticos, divisão com aberturas) estão sempre no idioma predefinido da sua conta – seja qual for o idioma usado pelo cliente.
  • state é o estado em bruto do configurador (códigos, não textos). Permite recarregar o projeto em 3D a qualquer momento; não o altere.
  • room.cart (só no modo de preços): carrinho com códigos de produto, quantidades e valores se o cliente colocou os seus objetos 3D ou azulejos na divisão.
  • O formato é estável – apenas se acrescentam campos novos, os existentes nunca mudam de nome.

Faturação e limites

  • Paga por cada pedido recebido o preço acordado sem IVA; a fatura mensal chega por e-mail (PDF), o extrato de pedidos está na sua conta.
  • API: 600 chamadas / 10 minutos por chave; webhook: 8 s para responder, 4 tentativas.
  • O pedido do cliente tem limite de tamanho (projeto até 350 kB, nota 4 000 caracteres, fotos até 2 MB) – acima disso, o browser recusa antes de enviar.

Lista de verificação antes do arranque

  • Os domínios do seu site estão na conta (bloqueio de chave) e o configurador carrega na página real.
  • Webhook: assinatura verificada sobre o corpo em bruto, 2xx em menos de 8 s, duplicados por X-Furniconf-Delivery ignorados, teste aprovado.
  • A chave API está nos segredos do servidor; em caso de fuga, gere uma nova na conta (a antiga deixa de valer de imediato).
  • Devolve os estados dos pedidos (status) – também são visíveis na conta.
  • O marceneiro recebe o view-link, não o JSON.
  • A faturação por pedido está configurada (caso contrário falta o cartão Integração).

Códigos de erro (reason)

CódigoSignificado e solução
api_key (401)A chave fak_… é inválida ou foi gerada uma nova. Verifique o cabeçalho Authorization.
integration_off (403)A conta não tem faturação por pedido – webhook/API inativos. Escreva-nos.
suspended (403)A conta está suspensa.
rate_limit (429)Demasiadas chamadas (600 / 10 min). Abrande, use since.
license, quota, bad_email… (/api/quote)O configurador recusou o pedido: licença/domínio inválido (license), limite mensal excedido (quota), e-mail/telefone/comprimento inválidos (bad_email, bad_phone, too_long), projeto acima de 350 kB (too_big).

Suporte

Chat diretamente na conta (cartão Suporte) ou e-mail; num problema técnico anexe o reqId da resposta e o X-Furniconf-Delivery do webhook. Referência completa: PORTAL.md.