Authentication

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

ScopeAllows
claims:readList and read housing requests
claims:writeSubmit and amend housing requests
options:readRead matched homes
options:writePut homes on a request, set dates, place, cancel
placements:readRead the state of a placement

For housing providers

ScopeAllows
properties:readRead your portfolio and its readiness
properties:writePublish homes and set availability
bookings:readWhat has been booked on your homes
payouts:readWhether 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:

CallNeeds
Reading events, deliveries, usage, your endpointsany read scope
Replaying or simulating an eventany write scope
Creating, changing or deleting an endpointwebhooks:manage, or any write scope
POST /usage/keys/self/revokenothing — 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

PrefixWhat it does
Livehvn_pk_…Creates real housing requests and reaches real landlords
Sandboxhvn_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 keyA live key
Housing requestssandbox onlyreal only
Homes and availabilitytest inventory onlyreal inventory only
Charges and payment historysandbox onlyreal only
Eventssandbox and simulated onlyreal only
Webhook endpointssandbox endpoints onlylive 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 key600
Sandbox key120

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 body409 idempotency_key_reused. This is a bug in the caller, and silently overwriting would hide it.
  • First call still running409 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:

CallWhy it is already safe
POST /claimsKeyed by your external_id — a retry returns the same request
POST /properties with external_idUpdates that home in place
POST /claims/{ref}/options, /datesWrite the same row again

These are the ones that bite without a key:

CallWhat a retry costs you
POST /claims/{ref}/documentsThe same file attached twice
POST /claims/{ref}/source-moreThe same job in a coordinator’s queue twice
POST /properties / /batch without external_idA 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.