Webhooks

We notify you when something happens in the subaccount. If your system can't expose a public URL, skip to the polling alternative — it serves exactly the same events.

What you receive

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": "Q4 restock",
            "amount": "4200000", "currency": "CLP" }
}

data has the same shape the GET for that resource returns. It is not a summary: it is the complete object. If you receive contact.updated and then fetch the contact, you will see the same thing.

Update events also carry previous, with the fields that changed.

Verify the signature

It is not optional. Without verification, anyone who knows your URL can fake a payment received or a deal won.

The signature is t=<unix>,v1=<hex> where the HMAC-SHA256 covers t + "." + body using your endpoint's secret.

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');

  // Constant-time comparison: a `===` leaks information through how
  // long it takes to fail.
  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: you need the RAW body, not the parsed one.
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);       // deduplicate by evento.id BEFORE processing
  res.sendStatus(200);   // respond fast; process later
});

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: ask for the raw body.
@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;
}

Receiver rules

Deduplicate by id

A delivery can be repeated. If your endpoint is slow and the connection drops after processing but before responding, we retry. Store the ids you have already processed:

INSERT INTO eventos_hapee (id) VALUES ('evt_10433')
ON CONFLICT (id) DO NOTHING;   -- if nothing was inserted, you already processed it: exit

Order is not guaranteed

There are N endpoints with independent retries: a deal.won can arrive before the deal.stage_changed that preceded it. If order matters to you, sort by created_at, or fetch the current state with a GET instead of rebuilding it from the event sequence.

Respond fast

Any 2xx confirms. Queue it and process later: the timeout is 10 seconds, and a slow endpoint piles up retries that look like duplicates.

Retries

Increasing backoff (2^attempts minutes, capped at 60) for up to ~24 hours. After 15 consecutive deliveries without a 2xx the endpoint is disabled automatically and you are notified. The counter resets with every successful delivery, so an endpoint that goes down one afternoon and comes back is not switched off.

To reactivate it: PATCH /webhooks/endpoints/{id} with {"active": true}.

Rotate the secret

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

For 24 hours both secrets are valid and each delivery carries two signatures: X-Hapee-Signature (the new one) and X-Hapee-Signature-Previous (the old one).

That is why no downtime needs to be coordinated. Without the overlap you would have to change the secret at the exact same instant we do, and every event in flight during that window would arrive with a signature that doesn't validate.

During the window, accept either one. Once you have switched, keep only the new one.

Testing

Without going to production

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

It fires a real webhook.test, through the same queue, with the same signature and the same retries. A test button that takes a shortcut only tests the shortcut.

On your machine

Start a tunnel (ngrok http 3000, cloudflared tunnel) and register the temporary URL as an endpoint. Remember to delete it afterwards.

See what happened

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

# The detail, with the body that was sent
curl https://beta.hapee.ai/api/v1/webhooks/deliveries/88213 \
  -H "Authorization: Bearer hp_live_YOUR_CREDENTIAL"

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

Redelivering clones the delivery; it doesn't reopen the original: the record of what happened —with its status, attempts and error— is the only evidence of why a redelivery was needed. The clone carries the same event id, so your deduplication recognizes it.

Alternative: GET /events

If your system can't expose a public URL —typical for an ERP installed on-premises— you poll. Same events, same body.

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

The cursor is the id, never the date. Two events from the same millisecond with a date-based cursor get lost or repeated at the page boundary, and in a sync that means a sale you never saw.

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

There are currently 35:

GroupEvents
Contactscontact.created, .updated, .deleted, .tag_added, .tag_removed
Companiescompany.created, .updated, .archived
Dealsdeal.created, .updated, .stage_changed, .won, .lost, .deleted
Quotesquote.created, .sent, .viewed, .accepted, .rejected
Productsproduct.created, .updated, .archived
Custom objectscustom_object.record.created, .updated, .deleted
Appointmentsappointment.created, .updated, .cancelled
Formsform.submitted
Billing and collectionsinvoice.sent, .paid, .overdue, payment.received, refund.issued
Testwebhook.test

Subscribe with ["*"] to all of them, or to the list you care about. The wildcard is resolved at emit time, but the subscription is stored as a list: a new event in the catalog doesn't start being sent to anyone who didn't ask for it.