Webhooks
Te avisamos cuando pasa algo en la subcuenta. Si tu sistema no puede exponer una URL pública, salta a la alternativa por consulta — sirve exactamente los mismos eventos.
Cómo se ve lo que te llega
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": "Reposición Q4",
"amount": "4200000", "currency": "CLP" }
}
data tiene la misma forma que devuelve el GET de ese recurso. No es un resumen: es el objeto completo. Si recibes contact.updated y después consultas el contacto, vas a ver lo mismo.
En los eventos de actualización viaja además previous, con los campos que cambiaron.
Verificar la firma
No es opcional. Sin verificar, cualquiera que conozca tu URL puede inventar un pago recibido o un negocio ganado.
La firma es t=<unix>,v1=<hex> donde el HMAC-SHA256 cubre t + "." + cuerpo usando el secreto de tu endpoint.
- El timestamp entra en el mensaje firmado a propósito. Si solo se firmara el cuerpo, el
tsería decorativo y un cuerpo capturado con su firma valdría para siempre. - Verifica la firma ANTES de mirar el timestamp. El
tviene del propio encabezado: dejarlo decidir primero sería creerle a quien todavía no demostró nada. - Usa el cuerpo crudo, tal como llegó. Si lo parseas a JSON y lo vuelves a serializar, cambia un espacio y la firma no 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');
// Comparación en tiempo constante: un `===` filtra información por el
// tiempo que tarda en fallar.
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: hace falta el cuerpo CRUDO, no el parseado.
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); // deduplica por evento.id ANTES de procesar
res.sendStatus(200); // responde rápido; procesa después
});
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: pide el cuerpo crudo.
@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;
}
Reglas del receptor
Deduplica por id
Una entrega puede repetirse. Si tu endpoint tarda y se corta la conexión después de procesar pero antes de responder, lo reintentamos. Guarda los id que ya procesaste:
INSERT INTO eventos_hapee (id) VALUES ('evt_10433')
ON CONFLICT (id) DO NOTHING; -- si no insertó, ya lo procesaste: sal
El orden no está garantizado
Son N endpoints con reintentos independientes: un deal.won puede llegar antes que el deal.stage_changed que lo precedió. Si el orden te importa, ordena por created_at, o consulta el estado actual con un GET en vez de reconstruirlo desde la secuencia de eventos.
Responde rápido
Cualquier 2xx confirma. Encola y procesa después: el timeout es de 10 segundos, y un endpoint lento acumula reintentos que se ven como duplicados.
Reintentos
Espera creciente (2^intentos minutos, tope 60) hasta ~24 horas. Tras 15 entregas seguidas sin 2xx el endpoint se desactiva solo y se avisa. El contador vuelve a cero con cada entrega exitosa, así que un endpoint que se cae una tarde y vuelve no se apaga.
Para reactivarlo: PATCH /webhooks/endpoints/{id} con {"active": true}.
Rotar el secreto
curl -X POST https://beta.hapee.ai/api/v1/webhooks/endpoints/12/rotate-secret \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
Durante 24 horas los dos secretos son válidos y cada entrega viaja con dos firmas: X-Hapee-Signature (el nuevo) y X-Hapee-Signature-Previous (el viejo).
Por eso no hace falta coordinar una caída. Sin el traslape tendrías que cambiar el secreto en el mismo instante que nosotros, y todo evento en vuelo en esa ventana llegaría con una firma que no valida.
Durante la ventana, acepta cualquiera de las dos. Después de cambiar, quédate solo con la nueva.
Probar
Sin salir a producción
curl -X POST https://beta.hapee.ai/api/v1/webhooks/endpoints/12/ping \
-H "Authorization: Bearer hp_test_TU_CREDENCIAL"
Dispara un webhook.test real, por la misma cola, con la misma firma y los mismos reintentos. Un botón de prueba que use un atajo prueba el atajo.
En tu máquina
Levanta un túnel (ngrok http 3000, cloudflared tunnel) y registra la URL temporal como endpoint. Acuérdate de borrarlo después.
Ver qué pasó
# Últimas entregas
curl "https://beta.hapee.ai/api/v1/webhooks/deliveries?endpoint_id=12&status=failed" \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
# El detalle, con el cuerpo que se mandó
curl https://beta.hapee.ai/api/v1/webhooks/deliveries/88213 \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
# Reenviar
curl -X POST https://beta.hapee.ai/api/v1/webhooks/deliveries/88213/redeliver \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
Reenviar clona la entrega, no reabre la original: el registro de lo que pasó —con su estado, sus intentos y su error— es la única evidencia de por qué hubo que reenviar. El clon lleva el mismo id de evento, así que tu deduplicación lo reconoce.
Alternativa: GET /events
Si tu sistema no puede exponer una URL pública —lo habitual en un ERP instalado en la empresa— consultas tú. Mismos eventos, mismo cuerpo.
curl "https://beta.hapee.ai/api/v1/events?cursor=10432&limit=100" \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
- Guarda el
next_cursory pásalo la vez siguiente - Vienen del más viejo al más nuevo: te estás poniendo al día
- Retención: 30 días
- Filtra con
?type=deal.won,quote.accepted
El cursor es el id, nunca la fecha. Dos eventos del mismo milisegundo con un cursor por fecha se pierden o se repiten al cortar la página, y en una sincronización eso significa una venta que nunca viste.
Catálogo de eventos
curl https://beta.hapee.ai/api/v1/webhooks/events \
-H "Authorization: Bearer hp_live_TU_CREDENCIAL"
Hoy son 35:
| Grupo | Eventos |
|---|---|
| Contactos | contact.created, .updated, .deleted, .tag_added, .tag_removed |
| Empresas | company.created, .updated, .archived |
| Negocios | deal.created, .updated, .stage_changed, .won, .lost, .deleted |
| Cotizaciones | quote.created, .sent, .viewed, .accepted, .rejected |
| Productos | product.created, .updated, .archived |
| Objetos personalizados | custom_object.record.created, .updated, .deleted |
| Citas | appointment.created, .updated, .cancelled |
| Formularios | form.submitted |
| Facturación y cobranza | invoice.sent, .paid, .overdue, payment.received, refund.issued |
| Prueba | webhook.test |
Suscribite con ["*"] a todos, o a la lista que te importe. El comodín se resuelve al momento de emitir, pero la suscripción se guarda como lista: un evento nuevo en el catálogo no empieza a mandársele a quien no lo pidió.