Webhooks

Avisamos você quando algo acontece na subconta. Se seu sistema não pode expor uma URL pública, vá para a alternativa por consulta — ela entrega exatamente os mesmos eventos.

Como é o que chega até você

POST /hooks/hapee HTTP/1.1
Content-Type: application/json
User-Agent: Hapee-Webhooks/1.0
X-Hapee-Event: deal.won
X-Hapee-Delivery: 88213
X-Hapee-Signature: t=1789057409,v1=18af345942aa99c0...

{
  "id": "evt_10433",
  "type": "deal.won",
  "api_version": "v1",
  "created_at": "2026-09-10T14:02:11Z",
  "subaccount_id": 63,
  "data": { "id": 5521, "title": "Reposição Q4",
            "amount": "4200000", "currency": "CLP" }
}

data tem o mesmo formato que o GET desse recurso retorna. Não é um resumo: é o objeto completo. Se você recebe contact.updated e depois consulta o contato, verá o mesmo.

Nos eventos de atualização também vai previous, com os campos que mudaram.

Verificar a assinatura

Não é opcional. Sem verificar, qualquer pessoa que conheça sua URL pode inventar um pagamento recebido ou um negócio ganho.

A assinatura é t=<unix>,v1=<hex>, onde o HMAC-SHA256 cobre t + "." + corpo usando o segredo do seu endpoint.

Node

const crypto = require('crypto');

function firmaValida(cuerpoCrudo, cabecera, secreto, toleranciaSeg = 300) {
  const partes = Object.fromEntries(
    cabecera.split(',').map(p => p.split('=').map(s => s.trim()))
  );
  const { t, v1 } = partes;
  if (!t || !v1) return false;

  const esperado = crypto
    .createHmac('sha256', secreto)
    .update(`${t}.${cuerpoCrudo}`)
    .digest('hex');

  // Comparação em tempo constante: um `===` vaza informação pelo
  // tempo que leva para falhar.
  const a = Buffer.from(esperado, 'hex');
  const b = Buffer.from(v1, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  return Math.abs(Math.floor(Date.now() / 1000) - Number(t)) <= toleranciaSeg;
}

// Express: é preciso o corpo BRUTO, não o já convertido.
app.post('/hooks/hapee', express.raw({ type: 'application/json' }), (req, res) => {
  const crudo = req.body.toString('utf8');
  if (!firmaValida(crudo, req.get('X-Hapee-Signature'),
                   process.env.HAPEE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const evento = JSON.parse(crudo);
  encolar(evento);       // deduplique por evento.id ANTES de processar
  res.sendStatus(200);   // responda rápido; processe depois
});

Python

import hashlib, hmac, time

def firma_valida(cuerpo_crudo: bytes, cabecera: str, secreto: str,
                 tolerancia_seg: int = 300) -> bool:
    partes = dict(
        p.strip().split("=", 1) for p in cabecera.split(",") if "=" in p
    )
    t, v1 = partes.get("t"), partes.get("v1")
    if not t or not v1:
        return False

    esperado = hmac.new(
        secreto.encode("utf-8"),
        t.encode("utf-8") + b"." + cuerpo_crudo,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(esperado, v1):
        return False
    return abs(int(time.time()) - int(t)) <= tolerancia_seg
# FastAPI: peça o corpo bruto.
@app.post("/hooks/hapee")
async def hook(request: Request):
    crudo = await request.body()
    if not firma_valida(crudo, request.headers.get("x-hapee-signature", ""), SECRETO):
        raise HTTPException(401)
    evento = json.loads(crudo)
    encolar(evento)
    return {"ok": True}

PHP

<?php
function firma_valida(string $cuerpoCrudo, string $cabecera,
                      string $secreto, int $toleranciaSeg = 300): bool {
    $partes = [];
    foreach (explode(',', $cabecera) as $p) {
        $kv = explode('=', trim($p), 2);
        if (count($kv) === 2) { $partes[$kv[0]] = $kv[1]; }
    }
    if (empty($partes['t']) || empty($partes['v1'])) { return false; }

    $esperado = hash_hmac('sha256', $partes['t'] . '.' . $cuerpoCrudo, $secreto);

    if (!hash_equals($esperado, $partes['v1'])) { return false; }
    return abs(time() - (int)$partes['t']) <= $toleranciaSeg;
}

$crudo = file_get_contents('php://input');
$cabecera = $_SERVER['HTTP_X_HAPEE_SIGNATURE'] ?? '';
if (!firma_valida($crudo, $cabecera, getenv('HAPEE_WEBHOOK_SECRET'))) {
    http_response_code(401); exit;
}
$evento = json_decode($crudo, true);

C#

using System.Security.Cryptography;
using System.Text;

public static bool FirmaValida(string cuerpoCrudo, string cabecera,
                               string secreto, int toleranciaSeg = 300)
{
    var partes = cabecera.Split(',')
        .Select(p => p.Trim().Split('=', 2))
        .Where(kv => kv.Length == 2)
        .ToDictionary(kv => kv[0], kv => kv[1]);

    if (!partes.TryGetValue("t", out var t) || !partes.TryGetValue("v1", out var v1))
        return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secreto));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{cuerpoCrudo}"));
    var esperado = Convert.ToHexString(hash).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(esperado), Encoding.UTF8.GetBytes(v1)))
        return false;

    var ahora = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    return Math.Abs(ahora - long.Parse(t)) <= toleranciaSeg;
}

Regras do receptor

Deduplique por id

Uma entrega pode se repetir. Se seu endpoint demora e a conexão cai depois de processar mas antes de responder, tentamos novamente. Guarde os id que você já processou:

INSERT INTO eventos_hapee (id) VALUES ('evt_10433')
ON CONFLICT (id) DO NOTHING;   -- se não inseriu, você já processou: saia

A ordem não é garantida

São N endpoints com novas tentativas independentes: um deal.won pode chegar antes do deal.stage_changed que o antecedeu. Se a ordem importa para você, ordene por created_at, ou consulte o estado atual com um GET em vez de reconstruí-lo a partir da sequência de eventos.

Responda rápido

Qualquer 2xx confirma. Coloque na fila e processe depois: o timeout é de 10 segundos, e um endpoint lento acumula novas tentativas que parecem duplicatas.

Novas tentativas

Espera crescente (2^tentativas minutos, limite de 60) por até ~24 horas. Após 15 entregas seguidas sem 2xx o endpoint é desativado automaticamente e você é avisado. O contador volta a zero a cada entrega bem-sucedida, então um endpoint que cai numa tarde e volta não é desligado.

Para reativá-lo: PATCH /webhooks/endpoints/{id} com {"active": true}.

Rotacionar o segredo

curl -X POST https://beta.hapee.ai/api/v1/webhooks/endpoints/12/rotate-secret \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

Durante 24 horas os dois segredos são válidos e cada entrega vai com duas assinaturas: X-Hapee-Signature (a nova) e X-Hapee-Signature-Previous (a antiga).

Por isso não é preciso coordenar uma indisponibilidade. Sem a sobreposição, você teria que trocar o segredo no mesmo instante que nós, e todo evento em trânsito nessa janela chegaria com uma assinatura que não valida.

Durante a janela, aceite qualquer uma das duas. Depois de trocar, fique apenas com a nova.

Testar

Sem ir para produção

curl -X POST https://beta.hapee.ai/api/v1/webhooks/endpoints/12/ping \
  -H "Authorization: Bearer hp_test_SUA_CREDENCIAL"

Dispara um webhook.test real, pela mesma fila, com a mesma assinatura e as mesmas novas tentativas. Um botão de teste que usa um atalho testa o atalho.

Na sua máquina

Abra um túnel (ngrok http 3000, cloudflared tunnel) e registre a URL temporária como endpoint. Lembre-se de apagá-lo depois.

Ver o que aconteceu

# Últimas entregas
curl "https://beta.hapee.ai/api/v1/webhooks/deliveries?endpoint_id=12&status=failed" \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

# O detalhe, com o corpo que foi enviado
curl https://beta.hapee.ai/api/v1/webhooks/deliveries/88213 \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

# Reenviar
curl -X POST https://beta.hapee.ai/api/v1/webhooks/deliveries/88213/redeliver \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

Reenviar clona a entrega, não reabre a original: o registro do que aconteceu —com seu status, suas tentativas e seu erro— é a única evidência de por que foi preciso reenviar. O clone leva o mesmo id de evento, então sua deduplicação o reconhece.

Alternativa: GET /events

Se seu sistema não pode expor uma URL pública —o habitual em um ERP instalado na empresa— você consulta. Mesmos eventos, mesmo corpo.

curl "https://beta.hapee.ai/api/v1/events?cursor=10432&limit=100" \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

O cursor é o id, nunca a data. Dois eventos do mesmo milissegundo com um cursor por data se perdem ou se repetem ao cortar a página, e em uma sincronização isso significa uma venda que você nunca viu.

curl https://beta.hapee.ai/api/v1/webhooks/events \
  -H "Authorization: Bearer hp_live_SUA_CREDENCIAL"

Hoje são 35:

GrupoEventos
Contatoscontact.created, .updated, .deleted, .tag_added, .tag_removed
Empresascompany.created, .updated, .archived
Negóciosdeal.created, .updated, .stage_changed, .won, .lost, .deleted
Cotaçõesquote.created, .sent, .viewed, .accepted, .rejected
Produtosproduct.created, .updated, .archived
Objetos personalizadoscustom_object.record.created, .updated, .deleted
Compromissosappointment.created, .updated, .cancelled
Formuláriosform.submitted
Faturamento e cobrançainvoice.sent, .paid, .overdue, payment.received, refund.issued
Testewebhook.test

Assine com ["*"] para todos, ou para a lista que lhe interessa. O curinga é resolvido no momento da emissão, mas a assinatura é guardada como lista: um evento novo no catálogo não começa a ser enviado a quem não o pediu.