Authentication
Every call carries an organisation API key.
curl 'https://api.havnly.ai/functions/v1/claims' \
-H 'Authorization: Bearer hvn_pk_…'x-api-key: hvn_pk_… works too, if that suits your HTTP client better.
Keys belong to your organisation
Not to a person. An integration built on someone’s personal key stops working the day they leave, so a key here is issued to the company, and keys tied to an individual are refused outright.
Create them in Havnly → API Console → Keys. The key is shown once, at creation — Havnly stores only a hash of it and cannot show it again. If it is lost, revoke it and issue another.
Scopes
A key carries only the permissions it needs:
For carriers
| Scope | Allows |
|---|---|
claims:read | List and read housing requests |
claims:write | Submit and amend housing requests |
options:read | Read matched homes |
options:write | Put homes on a request, set dates, place, cancel |
placements:read | Read the state of a placement |
For housing providers
| Scope | Allows |
|---|---|
properties:read | Read your portfolio and its readiness |
properties:write | Publish homes and set availability |
bookings:read | What has been booked on your homes |
payouts:read | Whether we can pay you |
Shared surfaces
/events, /webhooks and /usage serve both sides, so they are gated by
kind of access rather than by a particular resource:
| Call | Needs |
|---|---|
| Reading events, deliveries, usage, your endpoints | any read scope |
| Replaying or simulating an event | any write scope |
| Creating, changing or deleting an endpoint | webhooks:manage, or any write scope |
POST /usage/keys/self/revoke | nothing — see below |
Registering an endpoint is how data leaves Havnly, so it is a write. A read-only key cannot open a delivery channel: if it could, a leaked read key would become a standing way to receive every placement and payment your organisation ever sees.
Revoking the key in your own hand needs no scope at all. Whatever a key can or cannot do, turning it off must never be the thing it is not allowed to do — that is the call somebody makes at two in the morning about a key that has appeared in a public repository.
A call outside a key’s scopes answers 403 with {"error":{"code":"forbidden"}},
and the message names what was missing.
Live and sandbox
| Prefix | What it does | |
|---|---|---|
| Live | hvn_pk_… | Creates real housing requests and reaches real landlords |
| Sandbox | hvn_sk_… | For building against, without contacting anyone real |
Build with a sandbox key. Switch to live when your integration is doing what you expect.
The two environments do not see each other
A sandbox key and a live key on the same organisation are completely separated, across every surface and both ways:
| A sandbox key | A live key | |
|---|---|---|
| Housing requests | sandbox only | real only |
| Homes and availability | test inventory only | real inventory only |
| Charges and payment history | sandbox only | real only |
| Events | sandbox and simulated only | real only |
| Webhook endpoints | sandbox endpoints only | live endpoints only |
Anything belonging to the other environment answers 404 — the same answer you
would get for another organisation’s reference.
That last row matters more than it looks: a sandbox key cannot delete your live endpoint. If it could, a test script could silently stop every event reaching your production system, and nothing would look broken until somebody noticed a placement nobody had heard about.
This is the property that makes a sandbox key safe to hand to a developer, or paste into a test suite, or commit by accident. A test key that could end a real family’s housing would not be a test key.
Your external_id is unique per environment, not globally. Test with your
real claim numbers and then go live with the same ones — you will get two
separate requests, one in each environment, and each stays idempotent within
its own.
Sandbox inventory stays in sandbox
A home published with a sandbox provider key is marked as test data. It
behaves normally for you — it appears in your portfolio, it can become
listed, and a sandbox housing request will match it — but a live request
will never be offered it.
That asymmetry is deliberate. A sandbox request needs something to match against, so it sees both real and test homes. A real displaced household must never be offered a listing somebody published while building an integration, so the traffic only ever flows one way.
Publish with a live key and the home is real inventory, matchable by anyone.
Keeping a key safe
Treat it as a password: server side only, never in a browser, a mobile app or a repository. It can submit housing requests and place homes in your name.
Rotate by creating the new key, deploying it, then revoking the old one — both work at once, so there is no gap.
Rate limits
Every response tells you where you stand:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 573
X-RateLimit-Reset: 2026-09-22T18:43:00.000Z| Requests per minute | |
|---|---|
| Live key | 600 |
| Sandbox key | 120 |
The window is a fixed minute, and the limit is per key — a nightly sync on one key cannot starve the claims system running on another. Issue a separate key per integration and they stay out of each other’s way.
Going over returns 429 rate_limited with a Retry-After in seconds:
{
"error": {
"code": "rate_limited",
"message": "Over 600 requests a minute on this key. The window resets at 2026-09-22T18:43:00.000Z."
}
}Honour Retry-After rather than retrying immediately. If you are retrying a
write, send the same Idempotency-Key you used the first
time — a 429 means the work did not happen, and the key costs nothing.
Need more? These are defaults, not ceilings. A portfolio sync that needs a higher limit gets one — tell us the key and the shape of the traffic.
If our limiter is unavailable it lets you through. We would rather serve an extra call than refuse a displaced family’s housing request because a counter table was busy.
Failed authentication
Requests with a missing or invalid key are limited by address instead, at 60 a
minute. If you are seeing 429 on 401s, the key is wrong — fix the key
rather than backing off.
Retrying safely
Send an Idempotency-Key header on any write you might retry. A client
library that retries a timed-out request is doing the right thing; without a
key, so is the server, and you end up with two of whatever you sent.
curl -X POST 'https://api.havnly.ai/functions/v1/properties/batch' \
-H 'Authorization: Bearer hvn_pk_…' \
-H 'Idempotency-Key: 8f14e45f-ea1f-4a2b-9c3d-77b0aa10c2e1' \
-H 'content-type: application/json' \
-d '{ "properties": [ … ] }'Use a fresh key per logical operation — a UUID is ideal. Then:
- Same key, same body → the first response is replayed, byte for byte. The work is not done twice.
- Same key, different body →
409 idempotency_key_reused. This is a bug in the caller, and silently overwriting would hide it. - First call still running →
409 request_in_progress. Retry in a moment. - No key → nothing is remembered, and a retry does the work again.
Keys are remembered for 48 hours, and are scoped to your organisation, so your key text can never collide with another partner’s.
Where it matters
Some writes are already safe and need no key:
| Call | Why it is already safe |
|---|---|
POST /claims | Keyed by your external_id — a retry returns the same request |
POST /properties with external_id | Updates that home in place |
POST /claims/{ref}/options, /dates | Write the same row again |
These are the ones that bite without a key:
| Call | What a retry costs you |
|---|---|
POST /claims/{ref}/documents | The same file attached twice |
POST /claims/{ref}/source-more | The same job in a coordinator’s queue twice |
POST /properties / /batch without external_id | A second copy of every home |
That last one is the expensive one. A retried batch of a hundred homes with no
external_id doubles that part of your portfolio, and nothing downstream can
tell the copies apart. Send external_id on every home, an Idempotency-Key
on every batch, or both.
A failed call does not burn the key
A 500 releases the key, so a retry genuinely retries. A 4xx is a settled
answer and is replayed — if you sent something invalid, sending it again with
the same key gives you the same explanation rather than a different one.