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.
- The timestamp is part of the signed message on purpose. If only the body were signed,
twould be decorative and a captured body with its signature would be valid forever. - Verify the signature BEFORE looking at the timestamp. The
tcomes from the header itself: letting it decide first would mean trusting someone who hasn't proven anything yet. - Use the raw body, exactly as it arrived. If you parse it to JSON and serialize it again, a single space changes and the signature won't validate.
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"
- Save the
next_cursorand pass it the next time - They come oldest to newest: you are catching up
- Retention: 30 days
- Filter with
?type=deal.won,quote.accepted
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.
Event catalog
curl https://beta.hapee.ai/api/v1/webhooks/events \
-H "Authorization: Bearer hp_live_YOUR_CREDENTIAL"
There are currently 35:
| Group | Events |
|---|---|
| Contacts | contact.created, .updated, .deleted, .tag_added, .tag_removed |
| Companies | company.created, .updated, .archived |
| Deals | deal.created, .updated, .stage_changed, .won, .lost, .deleted |
| Quotes | quote.created, .sent, .viewed, .accepted, .rejected |
| Products | product.created, .updated, .archived |
| Custom objects | custom_object.record.created, .updated, .deleted |
| Appointments | appointment.created, .updated, .cancelled |
| Forms | form.submitted |
| Billing and collections | invoice.sent, .paid, .overdue, payment.received, refund.issued |
| Test | webhook.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.