Webhooks

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

EventFires when
request.status_changedA housing request moves through its lifecycle
option.declinedThe household turned a home down — carries their reason
option.savedThe household saved a home as one they want
dates.proposedThe household asked for different move-in dates
lease.sentThe contract goes out for signature
lease.first_signedThe tenant, or whoever you named, has signed
lease.signedBoth parties have signed — payment is unlocked
payment.succeededA charge cleared, with the fee breakdown
payment.failedA charge did not go through
booking.confirmedPaid 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

EventFires when
booking.confirmedA family is booked into one of your homes
booking.updatedA booking changes state, including cancellation
payment.succeededMoney reached your account, with gross, fee and net
payment.failedA charge against one of your homes did not go through

Managing endpoints

GET /webhooksYour endpoints. Never the secret — that is shown once
POST /webhooksRegister one
PATCH /webhooks/{id}Change the url, the subscriptions, or is_active
DELETE /webhooks/{id}Remove one
POST /webhooks/{id}/rotate-secretA new signing secret, same endpoint
POST /webhooks/{id}/testSend yourself a ping
GET /webhooks/deliveriesWhat 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-secret

The 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}/test

Queues 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/deliveries

Every 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-Signaturet=<unix>,v1=<hex hmac sha256>
X-Havnly-EventThe event name
X-Havnly-DeliveryUnique 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"])) < 300

Use 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.