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.

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"

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.

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

Hoy son 35:

GrupoEventos
Contactoscontact.created, .updated, .deleted, .tag_added, .tag_removed
Empresascompany.created, .updated, .archived
Negociosdeal.created, .updated, .stage_changed, .won, .lost, .deleted
Cotizacionesquote.created, .sent, .viewed, .accepted, .rejected
Productosproduct.created, .updated, .archived
Objetos personalizadoscustom_object.record.created, .updated, .deleted
Citasappointment.created, .updated, .cancelled
Formulariosform.submitted
Facturación y cobranzainvoice.sent, .paid, .overdue, payment.received, refund.issued
Pruebawebhook.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ó.