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.
- O timestamp entra na mensagem assinada de propósito. Se apenas o corpo fosse assinado, o
tseria decorativo e um corpo capturado com sua assinatura valeria para sempre. - Verifique a assinatura ANTES de olhar o timestamp. O
tvem do próprio header: deixá-lo decidir primeiro seria acreditar em quem ainda não provou nada. - Use o corpo bruto, exatamente como chegou. Se você o converte para JSON e o serializa de novo, muda um espaço e a assinatura não valida.
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"
- Guarde o
next_cursore passe-o na vez seguinte - Vêm do mais antigo ao mais novo: você está se atualizando
- Retenção: 30 dias
- Filtre com
?type=deal.won,quote.accepted
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.
Catálogo de eventos
curl https://beta.hapee.ai/api/v1/webhooks/events \
-H "Authorization: Bearer hp_live_SUA_CREDENCIAL"
Hoje são 35:
| Grupo | Eventos |
|---|---|
| Contatos | contact.created, .updated, .deleted, .tag_added, .tag_removed |
| Empresas | company.created, .updated, .archived |
| Negócios | deal.created, .updated, .stage_changed, .won, .lost, .deleted |
| Cotações | quote.created, .sent, .viewed, .accepted, .rejected |
| Produtos | product.created, .updated, .archived |
| Objetos personalizados | custom_object.record.created, .updated, .deleted |
| Compromissos | appointment.created, .updated, .cancelled |
| Formulários | form.submitted |
| Faturamento e cobrança | invoice.sent, .paid, .overdue, payment.received, refund.issued |
| Teste | webhook.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.