Webhooks
The interesting half of a placement is asynchronous: a household applies, a landlord approves, a lease is signed, money arrives. Webhooks tell you as it happens, so you are not polling every request every few minutes and still learning things late.
Register endpoints over the API, or in Havnly → API Console → Webhooks.
POST /webhooks{
"url": "https://your-system.example.com/havnly",
"events": ["option.declined", "dates.proposed", "lease.signed"],
"description": "Production claims listener"
}{
"data": {
"id": "4648c772-…",
"url": "https://your-system.example.com/havnly",
"events": ["option.declined", "dates.proposed", "lease.signed"],
"secret": "whsec_0d2ff178…",
"secret_note": "Store this now. Every delivery is signed with it and we will not show it again."
}
}The endpoint must be https — we will not post placement data over plain
http. Omit events to receive everything.
An event name we do not send is refused, not quietly accepted. A typo’d subscription is an endpoint that stays silent forever and a fortnight of wondering why.
Events
For carriers and TPAs
| Event | Fires when |
|---|---|
request.status_changed | A housing request moves through its lifecycle |
option.declined | The household turned a home down — carries their reason |
option.saved | The household saved a home as one they want |
dates.proposed | The household asked for different move-in dates |
lease.sent | The contract goes out for signature |
lease.first_signed | The tenant, or whoever you named, has signed |
lease.signed | Both parties have signed — payment is unlocked |
payment.succeeded | A charge cleared, with the fee breakdown |
payment.failed | A charge did not go through |
booking.confirmed | Paid and booked; the stay is live |
option.declined and dates.proposed are the two that need a person: a
placement stops at both until somebody answers.
For housing providers
| Event | Fires when |
|---|---|
booking.confirmed | A family is booked into one of your homes |
booking.updated | A booking changes state, including cancellation |
payment.succeeded | Money reached your account, with gross, fee and net |
payment.failed | A charge against one of your homes did not go through |
Managing endpoints
GET /webhooks | Your endpoints. Never the secret — that is shown once |
POST /webhooks | Register one |
PATCH /webhooks/{id} | Change the url, the subscriptions, or is_active |
DELETE /webhooks/{id} | Remove one |
POST /webhooks/{id}/rotate-secret | A new signing secret, same endpoint |
POST /webhooks/{id}/test | Send yourself a ping |
GET /webhooks/deliveries | What we sent, and what came back |
Separate endpoints per deployment is the usual shape: one for development, one for staging, one for production, each with its own secret.
Rotating a secret
POST /webhooks/{id}/rotate-secretThe endpoint and its subscriptions stay; only the secret changes. Deliveries already queued were signed with the old secret, so accept both for a few minutes before you stop honouring the previous one.
This exists because the alternative was deleting the endpoint and creating another, which misses every event in between.
Proving it works
POST /webhooks/{id}/testQueues a ping through the ordinary delivery path — same signing, same
retries. A test that took a different route would prove nothing.
{ "event": "ping", "data": { "message": "If you are reading this, your endpoint works.", "test": true } }Then check what happened:
GET /webhooks/deliveriesEvery attempt with its status, the HTTP code you returned, your response body and the attempt count. When somebody says “we never got it”, this is the answer rather than a conversation across two companies’ logs.
What arrives
POST to your URL, with this body:
{
"id": "a7779991-1f6e-4453-955a-ff98d6f02545",
"event": "request.status_changed",
"created_at": "2026-09-21T21:41:38Z",
"data": {
"request_id": "dde5315a-…",
"reference": "REQ-640409",
"external_id": "YOUR-CLAIM-0002",
"status": "options_sent",
"previous_status": "housing_needs_collected"
}
}external_id is your own reference, on every event, so you can file it without
a lookup.
Headers:
| Header | |
|---|---|
X-Havnly-Signature | t=<unix>,v1=<hex hmac sha256> |
X-Havnly-Event | The event name |
X-Havnly-Delivery | Unique id for this delivery |
Verifying the signature
Sign "<timestamp>.<raw body>" with your endpoint’s secret and compare. Never
trust the body without this — the URL is reachable by anyone who learns it.
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
// Constant time: a plain === leaks the answer one byte at a time.
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
// Reject anything older than five minutes, so a captured delivery cannot be
// replayed at you later.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh;
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300Use the raw body. Parsing and re-serialising the JSON changes the bytes and the signature will not match.
Answering
Reply 2xx as soon as you have stored it. Do the work afterwards — a slow
handler is retried as a failure.
Anything else, or no answer within 10 seconds, is retried at 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up on. A deploy that takes your endpoint down for an hour therefore loses nothing.
Delivering more than once
A delivery can arrive twice — that is the price of retrying. Treat id as the
key and ignore one you have already handled.
When something is missing
API Console → Webhooks → Deliveries shows the last 50: what was sent, what came back, how many attempts, and a button to send it again. “We never received it” is answerable on one screen rather than across two companies’ logs.