Pular para o conteúdo

Integrar

Webhooks

Receba no seu servidor um aviso a cada solicitação e a cada resposta do cliente.

Nesta página

Configurar

Em Propostas › Integração, no cartão Webhook (dono e administradores):

  1. Informe o endereço https:// do seu servidor que vai receber os avisos. É um endereço por empresa, o mesmo nos dois modos. Apagar o endereço desliga o webhook.
  2. Ao salvar o primeiro endereço, a Lafra cria o segredo whsec_… e o mostra inteiro uma vez só. Guarde no seu servidor (ele confere a assinatura). “Gerar novo segredo” troca o segredo na hora: o anterior para de valer.
  3. Escolha os eventos. Já vêm marcados solicitacao.criada, proposta.aprovada e proposta.recusada.
  4. Use “Enviar teste” para conferir. Enviar teste.

O cartão mostra os últimos envios, e cada tentativa aparece na Atividade recente com o código que o seu servidor respondeu.

Eventos

EventoQuando
solicitacao.criadaUm pedido do formulário do site caiu em Solicitações (só em Produção).
proposta.vistaO cliente abriu a página da proposta. Conta no máximo uma visita a cada 6 horas por aparelho (IP).
proposta.aprovadaO cliente aprovou a proposta pela página dela (“Assinar e aprovar”).
proposta.recusadaO cliente recusou a proposta pela página dela.
webhook.testeO botão “Enviar teste” da Integração (sempre enviado, sem precisar marcar).
As respostas que a equipe registra à mão no app (sem o cliente usar a página da proposta) não geram eventos.

O corpo do envio

Todo envio é um POST com um JSON assim:

JSON
{
  "id": "a3c5e7f9-1b2d-4e6f-8a0c-2d4f6b8d0e1a",
  "tipo": "proposta.aprovada",
  "modo": "producao",
  "criado_em": "2026-10-29T14:32:07.512Z",
  "dados": { ... }
}
CampoDescrição
idO id do envio (o mesmo do cabeçalho Lafra-Entrega). Não muda nas novas tentativas.
tipoO evento.
modoproducao ou teste (só o “Enviar teste” do modo Teste).
criado_emQuando o evento aconteceu, em UTC (ISO 8601).
dadosO conteúdo do evento (abaixo).

dados de solicitacao.criada

Os campos que o formulário não pede (ou que a pessoa deixou vazios) vêm null (servicos, como lista vazia). O contato vem em telefone (no formato (21) 90000-1234) ou em email (em minúsculas). Os anexos trazem o nome e o tamanho em bytes; os arquivos ficam na Lafra.

anexos é a lista que o formulário declarou no envio: o evento sai logo que a solicitação é recebida, antes de os arquivos subirem. Os arquivos chegam depois, e só os que chegarem inteiros ficam na solicitação; um arquivo da lista pode não existir na Lafra.
JSON · dados
{
  "codigo": "SL-0007",
  "recebida_em": "2026-10-29T14:30:55.104Z",
  "nome": "Policarpo Quaresma",
  "empresa": "Sítio do Sossego Ltda.",
  "telefone": "(21) 90000-1234",
  "email": null,
  "documento": null,
  "endereco": "Estrada do Sossego, 1910, Rio de Janeiro - RJ",
  "tipo_obra": "Residencial unifamiliar",
  "area_m2": 860.5,
  "pavimentos": 2,
  "servicos": ["spt", "trado"],
  "prazo": "Até dezembro",
  "mensagem": "Terreno em aclive, com acesso por estrada de terra.",
  "anexos": [{ "nome": "planta-baixa.pdf", "tamanho": 482113 }],
  "origem": {
    "dominio": "www.sondagens-exemplo.com.br",
    "pagina": "/orcamento",
    "campanha": "google / cpc"
  }
}

dados de proposta.vista, proposta.aprovada e proposta.recusada

CampoDescrição
codigoO código da proposta.
titulo
clienteNome do cliente.
statusO status atual (os mesmos da API).
valor_totalTotal em centavos.
linkA página da proposta para o cliente.
quandoQuando aconteceu, em UTC.
vistasSó em proposta.vista: quantas visitas já contaram.
aceiteSó em proposta.aprovada: { "nome", "cargo" } de quem assinou, ou null.
motivoSó em proposta.recusada: Preço, Prazo, Fechou com outra empresa, Desistiu da obra, Outro ou null.
JSON · dados de proposta.aprovada
{
  "codigo": "PR-0042",
  "titulo": "SPT – Construtora Quincas Borba",
  "cliente": "Construtora Quincas Borba",
  "status": "aprovada",
  "valor_total": 1840000,
  "link": "https://lafra.app/p/5f2d8c1e-7a44-4b9e-8e0d-3c6b1a9f2e77",
  "quando": "2026-10-29T14:32:07.498Z",
  "aceite": { "nome": "Brás Cubas", "cargo": "Diretor de obras" }
}

Em webhook.teste, dados é um objeto vazio ({}).

Cabeçalhos

Pedido
POST /webhooks/lafra HTTP/1.1
Content-Type: application/json
User-Agent: Lafra-Webhook/1.0
Lafra-Evento: proposta.aprovada
Lafra-Entrega: a3c5e7f9-1b2d-4e6f-8a0c-2d4f6b8d0e1a
Lafra-Assinatura: t=1793284327,v1=5d41b3c8e0f2...
CabeçalhoDescrição
Lafra-EventoO evento (o mesmo de tipo).
Lafra-EntregaO id do envio (o mesmo de id).
Lafra-Assinaturat=<segundos desde 1970>,v1=<HMAC-SHA256 em hexadecimal>. Uma assinatura nova a cada tentativa.
User-AgentLafra-Webhook/1.0

Conferir a assinatura

Confira a assinatura em todo envio, antes de usar os dados. Assim você sabe que ele veio da Lafra e não foi alterado no caminho.

  1. Leia t e v1 do cabeçalho Lafra-Assinatura.
  2. Recuse se t estiver a mais de 5 minutos do relógio do seu servidor (protege contra o reenvio de um pedido antigo).
  3. Calcule o HMAC-SHA256 de <t>.<corpo> (o número, um ponto e o corpo exato, byte a byte, como chegou) com o segredo inteiro, whsec_… incluído, como chave.
  4. Compare com v1 em tempo constante (crypto.timingSafeEqual, hmac.compare_digest), nunca com ==.
Use o corpo cru. Se o seu framework já transformou o JSON em objeto e você o transformar em texto de novo, os espaços e a ordem podem mudar, e a assinatura não bate.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
// O segredo inteiro, com o "whsec_", numa variável de ambiente.
const SEGREDO = process.env.LAFRA_WEBHOOK_SEGREDO;

// express.raw: a assinatura é do corpo exato, antes de virar objeto.
app.post("/webhooks/lafra", express.raw({ type: "application/json" }), (req, res) => {
  const corpo = req.body.toString("utf8");
  if (!assinaturaValida(req.get("Lafra-Assinatura"), corpo)) {
    return res.sendStatus(400);
  }
  const evento = JSON.parse(corpo);
  // Use evento.id para não tratar o mesmo envio duas vezes.
  console.log(evento.tipo, evento.dados);
  res.sendStatus(200);
});

function assinaturaValida(cabecalho, corpo) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(cabecalho ?? "");
  if (!m) return false;
  const t = Number(m[1]);
  // Recusa assinaturas com mais de 5 minutos (para os dois lados).
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
  const esperado = crypto
    .createHmac("sha256", SEGREDO)
    .update(`${t}.${corpo}`, "utf8")
    .digest();
  const recebido = Buffer.from(m[2], "hex");
  return (
    recebido.length === esperado.length &&
    crypto.timingSafeEqual(esperado, recebido)
  );
}

app.listen(3000);
Python (Flask)
import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, abort, request

app = Flask(__name__)
# O segredo inteiro, com o "whsec_", numa variável de ambiente.
SEGREDO = os.environ["LAFRA_WEBHOOK_SEGREDO"].encode()


def assinatura_valida(cabecalho: str | None, corpo: bytes) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", cabecalho or "")
    if not m:
        return False
    t = int(m.group(1))
    # Recusa assinaturas com mais de 5 minutos (para os dois lados).
    if abs(int(time.time()) - t) > 300:
        return False
    esperado = hmac.new(SEGREDO, f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
    # compare_digest: comparação em tempo constante.
    return hmac.compare_digest(esperado, m.group(2))


@app.post("/webhooks/lafra")
def webhook_da_lafra():
    corpo = request.get_data()  # o corpo exato, em bytes
    if not assinatura_valida(request.headers.get("Lafra-Assinatura"), corpo):
        abort(400)
    evento = json.loads(corpo)
    # Use evento["id"] para não tratar o mesmo envio duas vezes.
    print(evento["tipo"], evento["dados"])
    return "", 200

Respostas e reenvios

Responda com um código 2xx em até 5 segundos: é o que conta como entregue. O conteúdo da resposta é ignorado. Se for demorar, responda primeiro e processe depois.

Qualquer outra coisa (outro código, redirecionamento, falta de resposta em 5 s, erro de conexão) é uma falha, e a Lafra tenta de novo, com o mesmo corpo e o mesmo id:

TentativaEspera depois da anterior
1ªna hora do evento
2ª1 minuto
3ª5 minutos
4ª30 minutos
5ª2 horas
6ª (última)12 horas

Depois da 6ª falha o envio é dado como perdido. A espera é o mínimo: as novas tentativas saem quando a Lafra processa a fila (a cada evento, a cada pedido à API e quando alguém abre a Integração), então podem atrasar um pouco além do tempo da tabela.

Como pode haver nova tentativa de um envio que o seu servidor já tratou (por exemplo, se a resposta demorou mais de 5 s), guarde o id e ignore os repetidos.

Regras do endereço

Para proteger a sua rede e a da Lafra, o endereço do webhook tem regras:

  • Só https://, na porta 443 (padrão), com até 500 letras, sem espaços e sem usuário ou senha no endereço.
  • Nada da rede interna: localhost, nomes terminados em .local, .internal, .localhost, .lan, .home, .corp…, e IPs privados, locais ou reservados (também IPv4 dentro de IPv6).
  • O nome é conferido no DNS ao salvar e de novo a cada envio.
  • A Lafra não segue redirecionamentos: um 301 ou 302 conta como falha.
  • 5 segundos no máximo por tentativa.

Para testar no seu computador, use um túnel com endereço https público.

Enviar teste

“Enviar teste” manda o evento webhook.teste uma vez só (sem novas tentativas) e mostra a resposta do seu servidor. O modo do corpo é o do modo aberto na Integração. Vale até 10 vezes por hora por empresa.

Os pedidos do formulário no modo Teste não geram solicitacao.criada: para ver um evento de verdade, use Produção num domínio autorizado.