# Requests and responses

Every endpoint under `/api/public/v1` shares the conventions on this page.

## Base URL

```
https://www.bookwithhaven.com/api/public/v1
```

## Required headers

```
Authorization: Bearer hvat_...
User-Agent: YourProduct/1.2.0 (you@example.com)
```

The `User-Agent` must name your product and carry a contact address. When an integration is responsible for a traffic spike or a wave of errors, this is how Haven reaches you before taking a blunter action.

It is checked, not merely logged. Haven records a product token against your application at approval — the name you gave on your application form, e.g. `YourProduct` — and once one is recorded, a request whose `User-Agent` does not contain it is refused with `400`. Send the same `User-Agent` from every process in your fleet.

Writes additionally require `Idempotency-Key` and `Content-Type: application/json`.

## Headers you must not send

`X-Haven-Client-Id` and `X-Haven-Workspace-Context` are rejected with `400` on this surface, and `error.data.rejectedHeaders` names the ones you sent. Both are first-party headers, and both assert something about identity that a partner token has no standing to assert. Which account your token acts inside is a property of the grant, decided when the host approved you, and it cannot be switched at request time.

Haven strips both at the edge before any handler runs, so the `400` is a courtesy rather than the control — but it is a `400` precisely so that an integration expecting to switch accounts finds out on its first call rather than quietly reading the wrong account's data forever. If you manage several hosts, you hold several grants and several tokens, one per host. See [How it fits together](../00-start-here/03-how-it-fits-together.md).

`X-Request-Id` is accepted and ignored. Haven mints its own request id; see `X-Haven-Request-Id` below.

## The response envelope

Every response, success or failure, has the same three keys.

```json
{ "success": true, "value": {}, "error": null }
```

```json
{
  "success": false,
  "value": null,
  "error": {
    "detail": "not_found",
    "message": "No reservation with that code.",
    "requestId": "0f7e4c1a-2b8d-4a3f-9c11-6e5b0d7a2f34"
  }
}
```

One shape means one decoder. `success` is authoritative; do not infer it from the presence of `value`, because a successful call can legitimately return `null`.

The OAuth endpoints are the exception. `/oauth/token`, `/oauth/revoke` and `/oauth/introspect` return the RFC shape so that standard OAuth libraries can parse them. The boundary is exact: everything under `/api/public/v1` uses the envelope, everything under `/api/public/oauth` does not.

## Response headers

| Header                    | Meaning                                             |
| ------------------------- | --------------------------------------------------- |
| `X-Haven-Request-Id`      | Identifies this request in Haven's logs. Log it.    |
| `X-Haven-Api-Version`     | The major version serving the request               |
| `X-Haven-Route`           | The endpoint that served it, e.g. `properties.list` |
| `X-Response-Time`         | Haven's own processing time for the request         |
| `X-RateLimit-Limit-*`     | Your ceiling for each active window                 |
| `X-RateLimit-Remaining-*` | What is left                                        |
| `X-RateLimit-Reset-*`     | When each window resets, epoch seconds              |
| `Retry-After`             | On a 429 only                                       |

`X-Haven-Request-Id` is the same string as `error.requestId` in a failure body, the same string in Haven's application logs, and the same string tagged in Haven's error tracking. It is a UUID Haven mints; if you send an `X-Request-Id` of your own it is not used for this. Quote it in any support request and the answer arrives materially faster.

A `500` may additionally carry `X-Correlation-Id`, which comes from Haven's outermost error handler and is a different string. Quote both if you have both; `X-Haven-Request-Id` is the one that is always present.

## Status codes

| Code | Meaning                                         |
| ---- | ----------------------------------------------- |
| 200  | Success                                         |
| 400  | Malformed request, or a header you may not send |
| 401  | Credential missing, expired or revoked          |
| 403  | Valid credential, insufficient permission       |
| 404  | Not found, or not yours                         |
| 409  | Conflict, including a source-of-truth lock      |
| 426  | Not used on this surface                        |
| 429  | Rate limited                                    |
| 500  | Haven's fault                                   |

404 rather than 403 for an object outside your grant is deliberate. Distinguishing "does not exist" from "exists but is not yours" would let a caller enumerate other accounts' objects.

## This API is not callable from a browser

There is no CORS on `/api/public/v1`. No `Access-Control-Allow-Origin` is returned and a preflight `OPTIONS` is refused, so a browser cannot call these endpoints from a page — deliberately, because it means a leaked access token cannot be spent from a victim's browser at all.

Call Haven from your own server. If your product is a single-page app, proxy through your backend, which is where your refresh token has to live anyway.

## Requests are strict

Unknown fields in a request body are an error, not silently dropped. A typo in an optional field name would otherwise look like a successful call that did nothing, and you would find out much later.

## Writes

Every write is `POST /api/public/v1/operations/{operation}`, with the operation's input as a JSON body. Reads are conventional REST. The asymmetry is explained in [How it fits together](../00-start-here/03-how-it-fits-together.md).

## Source-of-truth locks

A write into a domain owned by an external system is refused:

```json
{
  "success": false,
  "value": null,
  "error": {
    "detail": "conflict",
    "message": "Availability for this listing is managed by the host's channel manager.",
    "requestId": "0f7e4c1a-2b8d-…",
    "data": {
      "reason": "domain_locked",
      "lockedDomain": "AVAILABILITY",
      "pmsOwner": "hospitable",
      "propertyCode": "a1b2c3d4"
    }
  }
}
```

Refusing is the point. Accepting the write would let the next inbound sync overwrite it, and the host would report that your integration silently stopped working. Read `lockedDomains` on the listing first.
