# Haven API documentation The complete developer documentation for `/api/public/v1`, concatenated. Source: https://0.0.0.0:3000/developers Archive: https://0.0.0.0:3000/developers/download Pages: 29 Individual pages are fetchable as raw Markdown under https://0.0.0.0:3000/developer-docs/.md --- # Haven API Haven is a direct-booking and property management platform for short-term rentals. Hosts manage listings, calendars, rates, reservations and guest messaging here, and this API is how software you build reaches that data on a host's behalf. The API is read and write. An integration can pull a portfolio's listings and calendar, push availability blocks and rate overrides, read and respond to guest messages, and act on reservations. It reaches the same code paths the Haven web dashboard and the Haven mobile app use, so a write made through the API fans out exactly as it would if the host had made it themselves: channel manager push, guest email and SMS, pricing sync. ## What makes this API different Authorization is granular. Most platforms in this category offer a single key with two settings, read or write, across everything the account owns. Haven declares 24 requestable scopes, and a host consents to a named list rather than to a category. A pricing tool that asks for `rates:read` and `rates:write` gets exactly that, and cannot read a guest's email address or cancel a stay. Access is reviewed. There is no self-serve key. You apply, Haven reviews the application, and staff set the maximum scope set your application may ever request. Until that happens your client exists but authorizes nothing. Use of the API is governed by the [Developer and API Terms](/terms/developer-terms). Failures explain themselves. A 403 names the scope you were missing and returns a link that re-runs authorization with it included. A 429 names which limit you hit and when it resets. ## Where to start Read [Getting access](00-start-here/01-getting-access.md) first. It covers the application, what Haven looks for, and how sandbox credentials work, and you can build the entire integration against sandbox before anyone reviews you. [Quickstart](00-start-here/02-quickstart.md) walks the shortest path from credentials to an authenticated call. [How it fits together](00-start-here/03-how-it-fits-together.md) explains Haven's data model in the terms the API uses, which is worth twenty minutes before you design anything. ## Status The authorization server, the resource endpoints and webhooks are in active development against the specification published here. This documentation describes the contract they implement. Pages that describe an endpoint you cannot yet call say so at the top. The scope registry, the error taxonomy, the identity model and the approval process are built and are stable. What is documented on those pages is what the server does today. ## Getting help Write to info@bookwithhaven.com. Include the correlation id from the response header of the call you are asking about; it identifies the exact request in Haven's logs and turns most questions into a two-minute answer. --- # Getting access Haven does not issue API credentials on request. Every application is reviewed by a person, and that review decides the maximum set of permissions the application may ever ask a host for. This page explains what the process is, what it asks of you, and how to build without waiting for it. ## Why access is gated An approved application can read a host's guest list and change their calendar. The blast radius of a compromised or careless integration is somebody's business, and hosts have no practical way to audit the software they connect. Review is the control that makes granular scopes meaningful: scopes bound what an application can do once a host consents, and approval bounds what it is allowed to ask for in the first place. Practically, it also means Haven knows who to call. Every application carries a named security contact, and an integration that starts behaving oddly at two in the morning gets a phone call rather than a silent block. ## The stages An application moves through six states. Only two of them can authorize a host. | State | What it means | | ---------------- | ---------------------------------------------------------------------------------------------------------------- | | `DRAFT` | You are still filling in the application. Nothing works. | | `PENDING_REVIEW` | Submitted. Haven staff have it. Nothing works. | | `SANDBOX` | Approved for development. The full OAuth flow works against seeded test accounts. Real host data is unreachable. | | `APPROVED` | Live. Real hosts can authorize the application. | | `SUSPENDED` | Turned off by Haven, reversibly. Existing tokens stop working immediately. | | `REVOKED` | Terminal. | Sandbox is not a waiting room. A client in `SANDBOX` completes the whole authorization flow, holds real access tokens, and calls every endpoint its scopes allow. What it cannot do is reach a real host's account. That is deliberate: Haven would rather review working software than a form, and you would rather find out how the API behaves before you are queued behind a human. ## What the application asks for Have these ready. **About the company.** Legal name, the name hosts will see, your website, and two email addresses: one for support and one for security. The security address is where a disclosure or an incident notice goes, and it should reach someone who can act on it. **About the product.** What it does, who uses it, and what it needs Haven for. Write this for a reader who knows the industry and has never heard of you. Specificity shortens review; a description that could describe any product in the category lengthens it. **The scopes you want, and why.** Ask for what you need. An application requesting `guests:read` and `reservations:refund` for a channel-management product will be asked what for, and the answer determines whether it is granted. Narrow requests are approved faster, and you can always come back for more (see [Incremental authorization](../01-oauth/06-incremental-authorization.md)). **Your redirect URIs.** Every URI the authorization flow may return a host to. These are matched by exact string equality, so register the precise value your client will send. Details and the rules in [The authorize endpoint](../01-oauth/02-authorize-endpoint.md). **Your expected volume.** A rough monthly request estimate. This sets your initial rate-limit tier and is not a commitment. ## What Haven looks for Reviewers are asking four questions. Is the scope request proportionate to what the product does? Does the redirect URI belong to infrastructure you control, and is it https? Is there a real security contact? And, for anything touching guest personal data or money, is there a reason this product needs it that a narrower scope would not satisfy? Sensitive and restricted scopes need a named sign-off recorded against the application, not just an approval click. Expect a conversation about `guests:read`, `guidebook:secrets:read`, `messaging:send`, `reservations:cancel` and `reservations:refund`. ## Certification, for production access Moving from sandbox to live is a short technical review rather than a second wait. Haven checks that your integration does five things, all of which are documented and testable against sandbox: Verifies webhook signatures rather than trusting the payload. Honors `Retry-After` on a 429 instead of retrying immediately. Sends a real `User-Agent` naming your product and a contact address. Sends an `Idempotency-Key` on every write. Re-fetches the resource after a webhook rather than treating the webhook body as the record. Each of these is a support ticket Haven does not want to receive, and an integration that gets all five right is one that will not generate them. ## After approval Haven creates your OAuth client and shows the client secret exactly once. Store it before closing the page; it is stored hashed and cannot be recovered, only replaced. You can hold several live secrets at a time, which is what makes rotation an overlap rather than an outage. From there, [Quickstart](02-quickstart.md). ## Applying Fill in the form at [https://www.bookwithhaven.com/developers/apply](https://www.bookwithhaven.com/developers/apply). It asks for the material above and for the scopes you think you need — ask for what you will use, because a shorter list is approved faster and you can ask for more later without re-applying. You will get an email when it arrives. Credentials come after review, not with the acknowledgement: submitting creates an application, and no developer, client or secret exists for it until a named member of Haven staff creates one. If the form does not suit — you need to attach something, or your legal team wants a thread — email info@bookwithhaven.com with the subject `Public API access — ` instead. Use of the API is governed by the [Developer and API Terms](/terms/developer-terms). An administrator of your organization must accept the current version before production credentials are issued. --- # Quickstart The shortest path from an approved client to an authenticated call. Everything here works against sandbox credentials, so you can complete it before Haven has reviewed you for production. > The resource endpoints and the authorization server are in active development. This page describes the contract they implement. Until they ship, treat it as the specification you are building against rather than a live tutorial. ## What you need Your `client_id`, which looks like `hvci_` followed by an opaque string, and your `client_secret`, which looks like `hvcs_...`. If your client is public (a native app, a CLI, an MCP server) you have no secret and authenticate with PKCE alone. One registered redirect URI. For a first pass, `http://127.0.0.1:8976/callback` is fine; loopback URIs may use any port at request time, so you do not have to register the port your server happens to bind. ## 1. Send the host to authorize Generate a PKCE verifier and challenge. The verifier is 43 to 128 characters from the unreserved set; the challenge is its SHA-256 digest, base64url encoded without padding. ```bash VERIFIER=$(openssl rand -base64 60 | tr -d '=+/' | cut -c1-64) CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=') ``` Then build the authorization URL and open it in the host's browser. ``` https://www.bookwithhaven.com/oauth/authorize ?response_type=code &client_id=hvci_your_client &redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback &scope=account%3Aread%20listings%3Aread%20calendar%3Aread%20calendar%3Awrite &state= &code_challenge=$CHALLENGE &code_challenge_method=S256 ``` Scopes are space separated. `S256` is the only accepted challenge method; `plain` is rejected. `state` is required. The host sees what your application is, which of their accounts they are connecting, and a plain-language line for every scope you asked for. If they approve, Haven redirects to your `redirect_uri` with `code`, `state` and `iss`. ## 2. Exchange the code for tokens Within sixty seconds, and only once. ```bash curl -X POST https://www.bookwithhaven.com/api/public/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri="http://127.0.0.1:8976/callback" \ -d code_verifier="$VERIFIER" ``` ```json { "access_token": "hvat_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "hvrt_...", "scope": "account:read listings:read calendar:read calendar:write" } ``` Read the `scope` field rather than assuming you received what you asked for. Haven returns it on every token response, including when it is unchanged, precisely so a narrowing is visible to you at the moment it happens. ## 3. Call the API ```bash curl https://www.bookwithhaven.com/api/public/v1/me \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "User-Agent: YourProduct/1.2.0 (you@example.com)" ``` ```json { "success": true, "value": { "account": { "code": "b41c9e07", "displayName": "Cascade Rentals", "timezone": "America/Los_Angeles", "currencyCode": "USD" }, "application": { "clientId": "hvci_your_client", "name": "Your Product" }, "scopes": [ "account:read", "listings:read", "calendar:read", "calendar:write" ], "context": { "kind": "own-default", "role": "OWNER", "canWrite": true }, "connectedAt": "2026-08-31T14:02:11.000Z" }, "error": null } ``` `/me` is the call to make first in any integration. It tells you whose account you are holding, what you may do with it, and whether a write will succeed before you compose one. The `User-Agent` is not optional. It must name your product and carry a contact address, so that when your integration is responsible for a traffic spike Haven can reach you rather than guess. ## 4. Make a write Writes are a single endpoint. You name an operation and send its input. ```bash curl -X POST https://www.bookwithhaven.com/api/public/v1/operations/block_dates \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -H "User-Agent: YourProduct/1.2.0 (you@example.com)" \ -d '{"propertyCode":"a1b2c3d4","startDate":"2026-09-14","endDate":"2026-09-17","notes":"Owner stay"}' ``` The `Idempotency-Key` is required on every write. Retrying with the same key replays the stored result instead of blocking the dates twice. See [Idempotency](../03-conventions/06-idempotency.md). ## What to read next [The authorization flow](../01-oauth/01-authorization-flow.md) covers refresh, rotation and revocation, all of which you need before you ship. [Errors](../03-conventions/02-errors.md) is worth reading in full once. Haven's error bodies carry more than a message, and handling them properly removes most of the reasons you would otherwise write to support. --- # How it fits together Haven's vocabulary differs from the platforms you have probably integrated with. Twenty minutes here will save you a redesign later. ## Accounts, people and portfolios A Haven **account** owns listings, reservations and guests. A **person** is a human with a login. Usually they are the same thing, but not always, and the difference matters to an API integration. A person can act inside an account three ways. They may own it. They may hold a seat on someone else's account as a workspace member. Or they may manage it through an agency link, which is how a property manager operates a client's account without owning it. Your token is bound to one **(person, account) pair**, fixed at the moment the host approved you. It is not a header you send and cannot be switched at request time. An agency managing forty client accounts and connecting your application to all of them produces forty separate authorizations, each one revocable by the host it belongs to and each one billed its own rate limit. That is a deliberate trade: a single portfolio-wide token would be more convenient for you and impossible for any individual host to revoke. `/me` tells you which pair you are holding and what role it carries. A `VIEWER` role means every write will be refused no matter what scopes you hold, because a grant can never exceed the authority of the person who created it. ## Listings What Haven calls a **property** is what a guest would call a listing. Properties belong to an account, carry photos, capacity, location, check-in and check-out times, a base nightly rate and a currency. A property may be governed by an external system. Hosts commonly run a channel manager, and when they do, that system owns some subset of the property's data. Haven models this as **source-of-truth domains**: availability, rates, fees, booking rules, listing content, property content, reservations and guest CRM. Each domain has an owner. This is the single most important thing to understand about writing to Haven. A granted scope is necessary but not sufficient. If a host's channel manager owns the availability domain for a listing, your `calendar:write` scope is real, your token is valid, and the write is still refused, because accepting it would mean the next inbound sync silently overwrote you. Haven refuses these explicitly with a `domain_locked` error naming the domain and the system that owns it, rather than accepting a write that will not survive. Every listing payload carries `pmsOwner` and `lockedDomains` so you can tell before you compose a write, and the planned `integrations:read` scope exposes a per-listing sync policy endpoint for the same purpose at sync time. ## Calendars and reservations A **reservation** is a stay. Haven's schema calls the underlying row an event, and you will occasionally see that word in error messages, but the API says reservation throughout. A calendar span is either a reservation, a host block, or a reservation on a sibling listing in a linked calendar group. Linked calendars are how a host models a property that can be booked whole or by room; booking one locks the others. When you read a calendar you get all three kinds, with a reservation code where one exists so you can join a span to its booking. Reservations carry an **origin**. A reservation that came from a channel partner cannot be cancelled through Haven, because Haven is not its system of record. Attempting it is refused rather than half-completed. ## Guests A guest is identified by email address rather than by a user row. Most guests never create a Haven login. A consequence worth designing around: the same person booking two stays at two different hosts is two independent guest records, and there is no cross-host guest identity to join on. Guest names, email addresses and phone numbers are gated behind `guests:read`, separately from `reservations:read`. A token holding only `reservations:read` sees the stay, the dates and the money, with the guest fields nulled. That split exists so an integration that needs to know a property is occupied does not also receive the occupant's contact details. ## Identifiers Top-level objects are addressed by an eight-character hex **reference code**, not by a database id. Codes are stable and are what appears in every payload, every webhook and every error. Codes are obfuscation, not authorization. Do not build anything on the assumption that a code is unguessable. Every request is authorized against the account your token is bound to, and a code belonging to another account returns 404 rather than 403, so you cannot use the API to discover whether an object exists outside your grant. Child rows inside an already-authorized parent, such as a price override on a listing, are addressed by integer id. See [Reference codes](../03-conventions/03-reference-codes.md). ## Reads and writes have different shapes Reads are REST. `GET /api/public/v1/properties`, `GET /api/public/v1/reservations/{code}`, cursor paginated, filtered by query parameter. Writes are not. Every write goes through `POST /api/public/v1/operations/{operation}` with a named operation and a JSON body. This is unusual and it is on purpose: one door means the scope check, the source-of-truth lock check, the read-only backstop and the idempotency ledger cannot be forgotten on one endpoint out of thirty. Each operation maps to the same internal action the Haven dashboard calls, which is why a write through the API notifies the guest, pushes to the channel manager and syncs pricing exactly as a write through the web app does. [Operations](../04-api/03-operations.md) lists them. --- # The authorization flow Haven implements OAuth 2.1 authorization code with PKCE. If you have integrated with a modern OAuth provider this will hold no surprises, and a standard client library will work. The details that differ are collected at the end. ## The shape A host connects your application in five steps. You send them to `/oauth/authorize` with your client id, a redirect URI, the scopes you want and a PKCE challenge. Haven authenticates them, shows what you are asking for, and they approve or decline. On approval Haven redirects back to you with a single-use authorization code. You exchange that code, plus the PKCE verifier, for an access token and a refresh token. From then on you call the API with the access token and rotate it with the refresh token. ## Discovery Haven publishes RFC 8414 metadata, so a library can configure itself: ``` GET https://www.bookwithhaven.com/.well-known/oauth-authorization-server ``` It also publishes RFC 9728 protected-resource metadata, at the root and at the path-inserted form, which is what lets an MCP client discover the authorization server from nothing but a 401: ``` GET https://www.bookwithhaven.com/.well-known/oauth-protected-resource GET https://www.bookwithhaven.com/.well-known/oauth-protected-resource/api/public/v1 ``` Two absences in the metadata are deliberate and worth reading as answers rather than omissions. There is no `registration_endpoint`, because Haven has no dynamic client registration: every client is created by a person after review. There is no `jwks_uri`, because tokens are opaque rather than signed. ## Endpoints | Purpose | Endpoint | | ---------- | ----------------------------------- | | Authorize | `GET /oauth/authorize` | | Token | `POST /api/public/oauth/token` | | Revoke | `POST /api/public/oauth/revoke` | | Introspect | `POST /api/public/oauth/introspect` | ## Tokens are opaque An access token is `hvat_` followed by random bytes. It is not a JWT, carries no claims, and cannot be inspected offline. The reason is revocation. Haven recomputes what a token may do on every single request, intersecting the token's scopes with the host's current grant and with the ceiling Haven staff set on your application. A host narrowing your access, an agency link being severed, a seat being revoked, or Haven reducing your approved scopes all take effect on your very next call, with no reissue and no propagation delay. A signed token would need a revocation list to achieve the same thing, which is the database lookup this design already performs. The practical consequence for you: do not cache authorization decisions, and read the `scope` field on every token response. ## What differs from a typical provider **PKCE is mandatory for every client**, including confidential ones holding a secret. `S256` only. `plain` is rejected, and `code_challenge_method` is required rather than defaulted. **`state` is required.** Formally it is optional when PKCE is present. Haven requires it because it is the only CSRF binding your own client has for its session, and every real library sends it. **Authorization codes live sixty seconds** and are consumed on first presentation, before the PKCE check runs. A failed exchange cannot be retried; re-run the authorization instead. This bounds a stolen code to a single verifier attempt. **Refresh tokens rotate.** Every refresh returns a new refresh token and invalidates the old one, with a sixty-second grace window for concurrent callers. Presenting a rotated token after that window is treated as theft and suspends the grant. See [Refreshing and rotation](04-refreshing-and-rotation.md). **`redirect_uri` is matched by exact string equality.** No normalization, no trailing-slash tolerance, no wildcards, no subdomain matching. The single exception is the port on a loopback address. **Errors from the OAuth endpoints use the RFC shape**, `{"error": "...", "error_description": "..."}`, not Haven's envelope. Everything under `/api/public/v1` uses the envelope. The boundary is exactly where a generic OAuth library stops reading and your own code starts. ## Sequence ``` Your app Host's browser Haven | | | |-- authorize URL ------------>| | | |-- GET /oauth/authorize->| | |<-- consent screen ------| | |-- approve ------------->| |<-- 302 ?code=&state=&iss= ---| | | | |-- POST /token (code + verifier + client auth) -------->| |<-- access_token, refresh_token, scope ----------------| | | |-- GET /api/public/v1/... (Bearer access_token) ------->| |<-- {success, value, error} ---------------------------| ``` Check `state` against what you sent before you use the code. Check `iss` equals `https://www.bookwithhaven.com` if your library supports RFC 9207; it is how you detect a mix-up attack when your client talks to more than one provider. --- # The authorize endpoint `GET /oauth/authorize` is a browser page, not an API call. Open it in the host's browser or system webview. Do not fetch it. ## Parameters All of these are required. | Parameter | Value | | ----------------------- | ----------------------------------------------------------------------- | | `response_type` | `code` | | `client_id` | Your `hvci_...` identifier | | `redirect_uri` | One of your registered URIs, byte for byte | | `scope` | Space-separated scope names | | `state` | Opaque value you generate and verify on return, 512 characters or fewer | | `code_challenge` | Base64url SHA-256 of your verifier, 43 characters, unpadded | | `code_challenge_method` | `S256` | ## Success Haven redirects to your `redirect_uri` with `code`, `state` and `iss` appended. The code is single-use and expires in sixty seconds. ## Failures split two ways, and the split matters If Haven cannot establish that your redirect target is trustworthy, it renders an error page on its own domain and does **not** redirect. That covers a missing or unknown `client_id`, an application that is not approved, a missing `redirect_uri`, and a `redirect_uri` that does not match a registered value. Redirecting an error to an unvalidated URI is how an authorization server becomes an open redirector, so Haven declines to. If the redirect target is validated and something else is wrong, Haven redirects with `error`, `error_description`, `state` and `iss` in the query string. That covers a bad `response_type`, a missing or `plain` PKCE challenge, an unknown scope, a scope outside your approved ceiling, a host whose seat is read-only, and the host declining. Codes you will see in the second case: `invalid_request`, `unsupported_response_type`, `invalid_scope`, `access_denied`, `server_error`, `temporarily_unavailable`. An unknown scope is refused rather than dropped. If you send `calendar:wrote`, you get `invalid_scope` naming it. Silently ignoring the typo would hand you a grant that does less than your code expects, and you would discover it in production. ## Redirect URI rules These are strict, and the strictness is the point. A permissive redirect matcher is the most common serious flaw in an OAuth deployment. **At registration**, a URI must be `https`, must parse to exactly what you typed, must carry no fragment, no userinfo, no wildcard and no encoded traversal, and must not use punycode. Haven normalizes nothing: if the parser would rewrite your URI, registration is refused and you are shown the canonical form to register instead. That is what makes exact matching safe at request time. Two exceptions exist for native and local development. `http://127.0.0.1` and `http://[::1]` may be registered with any path. Custom schemes are accepted for public clients only, and must be reverse-DNS with at least one dot: `com.yourcompany.app:/callback` is fine, `yourapp:/callback` is not, because a single-label scheme is first come, first served on the operating system and any other installed app can claim it. `localhost` is not accepted over http. Use the literal `127.0.0.1`. This trips up almost everyone, and the reason is that `localhost` resolves through the OS resolver and can be repointed by a hosts file or a hostile DNS server, while the literal cannot. **At request time**, the presented URI must equal a registered one byte for byte. Case, trailing slashes, default ports and query strings are all part of the string. The one exception: for a registered loopback URI, the port on the presented URI is ignored, because a native app binds an ephemeral port it cannot know in advance. Everything else about it still has to match. ## What the host sees The consent screen names your application, shows your logo, and states in plain text which domain they will be sent to after approving. It names the account being connected and their role in it. Each scope you requested appears as a sentence describing what it allows, drawn from the same registry the server enforces, so the screen cannot promise something different from what the token will do. Restricted scopes are never pre-selected. The host has to tick each one individually, and no "allow all" affordance covers them. Two things worth designing for. The grant covers every property on the account; there is no per-property authorization. And a host on a read-only seat cannot approve write scopes at all, so the screen refuses rather than issuing a grant that would fail on first use. --- # The token endpoint `POST /api/public/oauth/token`, form encoded. It serves two grant types and returns the RFC 6749 error shape rather than Haven's envelope. ## Authenticating your client Confidential clients may use HTTP Basic or form parameters. Basic is the standard's requirement and every library's default; form parameters exist because a meaningful number of HTTP stacks make setting an `Authorization` header on a token call awkward. Sending both is an error. ``` Authorization: Basic base64(client_id:client_secret) ``` Public clients send `client_id` and no secret. That is the correct configuration for native apps, CLIs and MCP servers: a secret shipped inside a binary is not a secret, and recording one in the database would make a security review read better than the system actually is. PKCE is what authenticates you. Presenting a secret as a public client is refused rather than ignored, because it usually means someone copied a confidential integration and believes they are authenticated. ## Exchanging an authorization code ``` grant_type=authorization_code code=hvoc_... redirect_uri=https://app.example.com/callback code_verifier= ``` `redirect_uri` must be byte-identical to the one you sent to `/authorize`. Sending `scope` here is an error; scope is fixed at authorization. ```json { "access_token": "hvat_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "hvrt_...", "scope": "account:read listings:read calendar:write" } ``` Access tokens live one hour, or twenty-four hours for a sandbox grant, because a developer at a REPL should not re-authenticate hourly. Refresh tokens live sixty days and rotate on every use. ## Refreshing ``` grant_type=refresh_token refresh_token=hvrt_... scope=listings:read # optional, narrowing only ``` Covered in [Refreshing and rotation](04-refreshing-and-rotation.md). ## Errors The body is `{"error": "...", "error_description": "..."}` with an optional `error_uri`. | Condition | `error` | Status | | -------------------------------------------------------------------------------------- | ------------------------- | ------ | | Body unparseable, over 8 KiB, or not form encoded | `invalid_request` | 400 | | `grant_type` missing | `invalid_request` | 400 | | `grant_type` not one of the two | `unsupported_grant_type` | 400 | | Two client authentication methods present | `invalid_request` | 400 | | Client unknown, secret wrong, revoked or expired | `invalid_client` | 401 | | Confidential client sent no secret | `invalid_client` | 401 | | Public client sent a secret | `invalid_client` | 401 | | Application not approved, suspended or revoked | `invalid_client` | 401 | | Required parameter missing | `invalid_request` | 400 | | `scope` sent on an authorization_code grant | `invalid_request` | 400 | | Code unknown, expired, already used, wrong client, redirect mismatch, or PKCE mismatch | `invalid_grant` | 400 | | Refresh token unknown, revoked, expired, or belonging to another client | `invalid_grant` | 400 | | Refresh token reused after the grace window | `invalid_grant` | 400 | | Host's grant no longer active | `invalid_grant` | 400 | | `scope` on refresh is not a subset | `invalid_scope` | 400 | | Rate limited | `temporarily_unavailable` | 429 | ### The five identical failures Unknown code, expired code, wrong client, redirect mismatch and PKCE mismatch all return byte-identical `invalid_grant` responses. Distinguishing them for you would distinguish them for someone probing, so the documentation carries what the response cannot: Check them in this order. Is the code more than sixty seconds old? Has this code already been exchanged, including by a retry of a request that timed out? Is the `redirect_uri` byte-identical to the authorize call, including trailing slash and case? Is the verifier the one whose challenge you sent, rather than a fresh one? Are you authenticating as the client the code was issued to? The common cause in practice is the second: a client retrying a failed exchange. Codes are consumed on first presentation, so the retry cannot succeed. Re-run the authorization. ### Rate limits The token endpoint is limited by attempts, not by successful mints. A refresh bug will not lock you out for the day; repeatedly guessing a client secret will slow you down. Failure buckets are consumed only on failure, so a healthy integration never touches them. Haven does not lock accounts out. A tripped limit returns 429 with `Retry-After` and drains on its own. ## Introspection `POST /api/public/oauth/introspect`, RFC 7662, confidential clients only. It exists for one reason. Effective scope is recomputed per request, so a host or Haven narrowing your access takes effect immediately and silently from your side; without introspection your first signal is a 403 in production. Introspection answers "is this token still good, and what does it still allow" from your control plane, and works on a token that is already dead. An inactive token returns exactly `{"active": false}` and nothing else, for every reason it might be inactive. An active one returns its scope, client id, expiry and a `sub` identifying the connection. --- # Refreshing and rotation Access tokens last an hour. Refresh tokens last sixty days and rotate on every use, which means the token you just spent is dead and the response carries its replacement. ``` POST /api/public/oauth/token grant_type=refresh_token refresh_token=hvrt_current ``` The response is a full token pair. Persist the new refresh token before you do anything else with the response; losing it means re-authorizing the host. ## Why rotation A stolen refresh token is a sixty-day credential. Rotation turns theft into something detectable: if both you and an attacker hold the same token, one of you spends it, and the other's attempt is a signal that would not exist otherwise. ## Concurrency, and the grace window A fleet of workers will eventually refresh the same token twice at once. Haven handles this rather than punishing it. For sixty seconds after a token rotates, a repeat presentation of the old token succeeds and returns a fresh pair. Both callers end up with working credentials. Beyond that window the same presentation is treated as theft. Haven cannot re-issue the first caller's exact tokens to the second, because tokens are stored hashed and the plaintext is gone the moment the response is written. Minting a second pair is what makes the race survivable without keeping plaintext credentials in a database, which is the tradeoff this design will not make. You should still hold a mutex around refresh in your own process. It is cheaper than the extra token pair, and it is what the grace window exists to forgive rather than to replace. ## Reuse detection Presenting a rotated refresh token after the grace window has passed revokes every access and refresh token on the grant, suspends the host's authorization, and alerts both your security contact and Haven. ```json { "error": "invalid_grant", "error_description": "This refresh token was already rotated. The authorization has been suspended and the host must re-approve." } ``` The message is explicit because your on-call needs to know the token chain forked rather than spending a day hunting a client bug, and because it tells an attacker nothing they do not already have. The grant is suspended, not revoked. The host sees the application paused with a reconnect prompt, which reads very differently from an application that silently stopped working, and reconnecting restores the same grant rather than creating a second one. The realistic cause is not theft. It is a client that persisted the old token, or two deployments sharing credentials from one database row and racing past sixty seconds. Both are worth finding. ## Narrowing on refresh You may request a subset of your current scopes: ``` grant_type=refresh_token refresh_token=hvrt_current scope=listings:read ``` A refresh never widens. The maximum is the scope set frozen on the token you are presenting, re-intersected with the host's grant and your approved ceiling. One consequence to design around: the refresh token returned alongside a narrowed access token inherits the narrower set. A least-privilege token cannot be refreshed back up to full scope, because a down-scoping that could be undone by a refresh would be decorative. If you want both a wide and a narrow credential, keep two chains. ## When a refresh fails `invalid_grant` on a refresh means the host's authorization is no longer usable: revoked, suspended, or the person who granted it lost the seat they granted it from. It does not mean your client is broken. The remedy is to send the host through authorization again. Treat it as a normal lifecycle event, surface it in your own UI as "reconnect Haven", and do not retry: no amount of retrying will make a revoked grant work, and the attempts count against your limit. --- # Revoking and disconnects A connection can end from three directions: you end it, the host ends it, or Haven ends it. Each looks different to your integration, and handling them properly is most of what separates an integration that ages well from one that generates support tickets. ## Revoking a token `POST /api/public/oauth/revoke`, RFC 7009. ``` token=hvat_... or hvrt_... token_type_hint=access_token | refresh_token # optional ``` The response is `200` with an empty body, always. An unknown token, an already-revoked token and a token belonging to a different client all return the same thing. That is required by the standard, and the reason is that any other behavior turns the endpoint into an oracle for whether a token exists. Revoking a refresh token also revokes every access token it minted and the rest of its rotation chain. Revoking an access token affects only that token. **Revoking does not end the host's authorization.** The grant is the host's consent, not your session. After revoking every token you hold, the connection still exists and you can obtain new tokens without asking the host again. If you want the connection genuinely gone, call the disconnect operation, which is a different thing and says so. That asymmetry is deliberate. A partner's "log out" quietly deleting a host's consent would be as surprising as the reverse. ## When the host disconnects Hosts manage connected applications in their Haven account and can revoke yours at any moment. When they do, every token dies immediately and your next call returns `401` with `invalid_token`. A refresh returns `invalid_grant`. There is no way to prevent this and no notice beforehand. Design for it: surface a clear reconnect path in your own interface, stop background work for that account rather than retrying, and do not treat it as an error condition worth alerting on. A host disconnecting an integration is a normal thing for a host to do. Once webhooks ship, `connection.revoked` will tell you at the moment it happens rather than on your next failed call. ## When Haven suspends Haven can suspend an application or a whole developer organization. Suspension is reversible and is used for incident response: a leaked secret, an integration behaving abusively, or a security disclosure being worked through. Your token calls return `403` with `partner_suspended`, and the token endpoint returns `invalid_client`. Haven contacts your security address. This is the reason that address has to reach a person who can act. A grant can also be suspended without any action by you or the host, when the authority behind it goes away: the person who authorized you loses their seat on the account, or the agency link they were acting through is severed. The host sees the application paused rather than removed, and restoring the seat restores the connection. ## Distinguishing the cases Your error handling should tell these apart, because the remedies differ completely. | What you see | What happened | What to do | | ------------------------------- | ------------------------------------------------------ | --------------------------------------- | | `401` `invalid_token` | Access token expired | Refresh | | `invalid_grant` on refresh | Host revoked, or the granting seat is gone | Prompt the host to reconnect | | `403` `partner_suspended` | Haven suspended you | Contact Haven; do not retry | | `403` `insufficient_scope` | Valid connection, missing permission | Re-authorize with the added scope | | `401` `invalid_client` at token | Your credentials are wrong or your app is not approved | Check the secret; check approval status | Only the first is routine. The third and fifth mean stop and involve a human. ## Rotating your client secret You can hold several live secrets at once, which makes rotation an overlap rather than a cutover: mint the new one, deploy it, confirm traffic has moved, then revoke the old one. Nothing breaks in between. The raw secret is displayed exactly once at creation. Haven stores a hash and cannot show it to you again, only replace it. --- # Incremental authorization Your product will need a permission you did not ask for at first. That is expected, and it does not require a second connection or a lost grant. ## How to ask Send the host through `/oauth/authorize` again with the **complete** scope set you want, not the difference. ``` scope=account:read listings:read calendar:read calendar:write rates:write ``` Absolute rather than incremental, because a delta requires you to know what you currently hold, and you may not: a host can narrow your access from their own account, and Haven staff can narrow your ceiling. The scope string you send is what the grant becomes. Any scope you omit is removed. If you send only the new one, you will lose everything else. ## What the host sees Three groups, in order: what they have already allowed, collapsed and de-emphasized; what is new, expanded and prominent; and what will be removed, with a warning. Most implementations hide the third group. Hiding it is how a partner silently drops a host's `guests:read` and the host spends a week wondering where guest names went. The host has to click, every time, even when you are asking for a subset of what they have already granted. An authorization endpoint that auto-approves is an endpoint any page on the internet can navigate a logged-in host's browser to. ## What happens to your existing tokens **Narrowing takes effect immediately, and revokes nothing.** Because Haven recomputes effective scope on every request, a token minted with `calendar:write` against a grant that no longer includes it will refuse the next write with `insufficient_scope`, while everything still granted keeps working. Your integration degrades rather than dies, which is what the host intended when they narrowed it. **Widening requires a new token.** Access tokens carry the scopes frozen at mint and never gain one. Complete the authorization flow and exchange the new code. A refresh cannot widen, because a refresh is bounded by the scopes on the token presenting it. That gives one invariant worth building on: > Scope can only ever be widened at `/oauth/authorize`, and that always passes through a human clicking approve. ## Handling insufficient_scope A 403 for a missing scope carries what you need to fix it: ```json { "success": false, "value": null, "error": { "detail": "insufficient_scope", "message": "This access token cannot perform \"create_price_override\": missing scope rates:write.", "requestId": "0f7e4c1a-2b8d-…", "data": { "missingScope": "rates:write", "grantedScopes": ["account:read", "listings:read", "calendar:read"], "reauthorizeUrl": "https://www.bookwithhaven.com/oauth/authorize?client_id=..." } } } ``` `reauthorizeUrl` is prebuilt with the union of what you hold and what you were missing, so the fix is a redirect rather than a support ticket. It still needs your PKCE parameters and `state` appended; treat it as the scope list worked out for you, not a finished URL. ## When staff narrow your ceiling Haven can reduce the maximum scope set your application may request. Existing grants are intersected against the new ceiling on the next request, so the effect is immediate and no tokens are invalidated. If you attempt to authorize with a scope outside your ceiling, `/authorize` refuses with `invalid_scope` naming it, rather than quietly issuing a narrower grant. A silent narrowing at authorization time would leave you believing you had a permission you did not, which surfaces later as an inexplicable 403. --- # How scopes work A scope is a named permission a host grants to your application. Haven declares 45 of them, of which 24 can be requested today. The full list is in the [Scope reference](02-scope-reference.md); this page is the model behind it. ## Naming Every scope is `resource:action`. Reads end in `:read`; everything else writes. ``` listings:read calendar:write reservations:cancel guests:read rates:write messaging:send ``` There are no wildcards. `calendar:*` is not a scope and never will be, because a wildcard is a permission whose meaning changes when Haven ships a feature, and a host cannot consent to something that does not exist yet. ## Four things have to agree What your token can do on any given request is the intersection of four sets: The scopes frozen on the access token when it was minted. The scopes on the host's current grant. The ceiling Haven staff approved for your application. And the set of scopes that are currently grantable at all. The intersection is recomputed on every request. Nothing is cached and nothing is trusted from the token itself. That is what makes revocation immediate: when a host narrows your grant, the next call reflects it. The practical rule: treat `/me` as authoritative for what you hold, re-read it after any authorization change, and never infer permissions from a token you were issued an hour ago. ## Dependencies are declared, never implied Some scopes are meaningless alone. `guests:read` exposes guest identity on reservations, so it needs `reservations:read` to have anything to attach to. Haven enforces these at authorization, not at request time. If you ask for `guests:read` without `reservations:read`, `/authorize` refuses and names what is missing. It does not silently add the dependency. Holding a scope never confers another one. There is no hierarchy, no implication and no inheritance: `reservations:write` does not include `reservations:read`, and `calendar:write` does not include `calendar:read`. Ask for what you need, explicitly. A permission the host did not read on the consent screen is a permission they did not grant. ## Tiers Every grantable scope carries a tier that governs how much review it needs and how the consent screen presents it. `standard` covers ordinary business data the host already sees on their own dashboard, or a reversible change to it. Any approved application may hold it. `sensitive` covers third-party personal data and irreversible actions: guest contact details, the contents of guest correspondence, cancelling a stay, taking a listing off the market, sending a message that reaches a real person. These need a named sign-off recorded against your application, and the consent row carries a warning. `restricted` covers arrival secrets and money leaving the account: `guidebook:secrets:read` and `reservations:refund`. Same sign-off, plus the consent row is never pre-selected and no bulk-approve affordance covers it. Every write behind a restricted scope also requires an explicit confirmation step. ## Declared but not grantable Two categories of scope appear in the reference that you cannot have. **Planned** scopes are real and specified but unbuilt: payouts, reviews, tasks, media upload, listing creation. Requesting one returns `invalid_scope` naming the release it is planned for, so you can design against a roadmap rather than emailing to ask. **Refused** scopes will never be granted to any application, and each carries a written reason. Billing and subscription management, payout destinations, the advertising wallet, team membership, workspace structure and account closure are all in this group. They touch the host's money or their control over their own account, and no integration is going to be the thing that moves either. Documenting the refusals rather than omitting them is the point. An absent scope is indistinguishable from an oversight; a refused one is an answer. ## Scope is necessary, not sufficient Two things can refuse a write your scope permits. The **host's own role**. A grant can never exceed the authority of the person who created it. If a host on a read-only seat authorizes your application, write scopes will not work, and Haven refuses at the consent screen rather than issuing a grant that would fail on first use. The **source-of-truth lock**. If a channel manager owns a listing's availability, a `calendar:write` call against that listing is refused with `domain_locked`, naming the domain and the owning system. Your scope is real; the listing is not yours to write. Read `lockedDomains` on the listing before composing the write. See [How it fits together](../00-start-here/03-how-it-fits-together.md). --- # Scope reference Every scope Haven declares, what it grants, and what it deliberately does not. This page is generated from the scope registry the server enforces, so it cannot describe a permission the code does not implement. If a scope is missing here, it does not exist. There are 45 declared scopes: 24 you can request today, 7 planned, and 14 that no application will ever hold. ## Tiers Every grantable scope carries a tier, which decides how much review it needs before Haven adds it to your application and how it appears on the host consent screen. | Tier | What it means | | --- | --- | | `standard` | Ordinary business data the host already sees on their own dashboard, or a reversible change to it. Any approved application may hold it. | | `sensitive` | Third-party personal data, or an irreversible action such as cancelling a stay or messaging a guest. Needs a named sign-off from Haven staff, and the consent screen carries a warning. | | `restricted` | Arrival secrets and money leaving the account. Same sign-off, and the consent row is never pre-ticked: the host has to select it individually. | ## Scopes you can request ### Account | Scope | Tier | What it allows | | --- | --- | --- | | `account:read` | standard | See which Haven account it is connected to and what it is allowed to do. | #### `account:read` See which Haven account it is connected to and what it is allowed to do. Grants: - The account reference code, display name, time zone and default currency - Which kind of context the grant acts in: your own account, a workspace seat, or an agency link - The effective role the grant carries (OWNER, MANAGER or VIEWER) - The connected app’s own name and client id - The exact scope list on the token, and when the connection was made - The current rate-limit budget and reset time Does not grant: - The name or email address of the person who approved the connection - Any listing, booking, guest, message or discount code - Billing state, subscription plan, payout balance or ad wallet - The list of other apps connected to this account ### Listings | Scope | Tier | What it allows | | --- | --- | --- | | `listings:read` | standard | Read your listings: titles, photos, capacity, city, time zone, currency and nightly rate. | | `listings:write` | standard | Change your listing content: title, description, house rules, amenities, capacity, and check-in and check-out times. | | `listings:publish` | sensitive | Archive or hide a listing, and turn booking or instant book on and off. | #### `listings:read` Read your listings: titles, photos, capacity, city, time zone, currency and nightly rate. Grants: - Every listing on the account, by reference code, including archived ones - Title, archived flag, photo set (up to 20) and total image count - Capacity: maxGuests, bedrooms, bathrooms, halfBaths, beds - Check-in and check-out times and the listing’s IANA time zone - City, region and country - Currency code and the default nightly rate in integer minor units - pmsOwner and lockedDomains, so a partner can tell what it may write Does not grant: - The street address, or any location finer than city — it is not on this surface at any scope - Wifi password, gate code, directions or check-in instructions — see guidebook:secrets:read - The guidebook PIN (Property.guideAccessPin), which is a credential and is never returned at any scope - Guests, bookings, messages or discount codes - Any change to a listing — see listings:write and listings:publish #### `listings:write` Change your listing content: title, description, house rules, amenities, capacity, and check-in and check-out times. Grants: - The update_listing_content operation, over the content half of a listing’s editable fields Does not grant: - Archiving or hiding a listing, or turning booking or instant book on and off — see listings:publish - Nightly rates, price overrides, promotions and holiday bumps — see rates:write - Minimum stays and check-in / check-out day rules — see stay-rules:write - The guidebook PIN, which no scope may write - The wifi network and password, gate code, directions, check-in method details, check-in instructions or check-in photos. Reading those is guidebook:secrets:read, which is restricted tier and needs a named staff sign-off; a standard content scope must not write by omission what a restricted scope is needed to read. - The listing’s contact email, phone number, custom domain or URL slug - Creating or deleting a listing — creation is declared as listings:create and deferred; deletion is not offered Writes target the `PROPERTY_CONTENT`, `LISTING_CONTENT` source-of-truth domains. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). #### `listings:publish` Archive or hide a listing, and turn booking or instant book on and off. **An app with this permission can stop your listings taking bookings.** Grants: - The set_listing_market_state operation, over exactly four fields: archived, hidden, allowBooking, instantBook Does not grant: - Any other listing field — the operation’s input schema carries these four keys and no others - Deleting a listing - Changing prices or rules on a listing it has hidden Writes target the `PROPERTY_CONTENT` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Calendar | Scope | Tier | What it allows | | --- | --- | --- | | `calendar:read` | standard | Read which dates are taken on your listings, and why. | | `calendar:write` | standard | Block dates on your calendar so they cannot be booked, and remove blocks again. | #### `calendar:read` Read which dates are taken on your listings, and why. Grants: - Every occupied span on a listing: a booking, a host block, or a sibling listing’s booking in a linked calendar group - Start and end date of each span and its block type - The reservation reference code where one exists, so a span can be joined to a booking - lockedDomains for the listing Does not grant: - Who is staying — the guest’s name and email need reservations:read plus guests:read - What a stay was charged — see reservations:read - Price overrides or promotions on those dates — see rates:read - Blocking or unblocking anything — see calendar:write #### `calendar:write` Block dates on your calendar so they cannot be booked, and remove blocks again. Grants: - The block_dates and unblock_dates operations Does not grant: - Cancelling or changing a real booking — see reservations:cancel - Pricing on the dates it blocks — see rates:write - Minimum stays — see stay-rules:write - Writing availability on a listing whose AVAILABILITY domain a channel manager owns, or that follows a linked master: those calls are refused with 409 domain_locked before anything is written Writes target the `AVAILABILITY` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Rates and promotions | Scope | Tier | What it allows | | --- | --- | --- | | `rates:read` | standard | Read your nightly price overrides, promotions and holiday price bumps. | | `rates:write` | standard | Create, change and remove nightly price overrides, promotions and holiday price bumps. | #### `rates:read` Read your nightly price overrides, promotions and holiday price bumps. Grants: - Per-night price overrides in integer minor units, with the listing’s currency code - Promotions and holiday price bump rules - The date window applied to each collection, echoed back - lockedDomains and pmsOwner for the listing Does not grant: - The listing’s base nightly rate — that is on the listing itself, see listings:read - What a specific booking was actually charged — see reservations:read - Discount codes — see discounts:read - Payout amounts or fee breakdowns paid to Haven #### `rates:write` Create, change and remove nightly price overrides, promotions and holiday price bumps. Grants: - create_price_override, update_price_override, delete_price_override, clear_price_overrides_for_range - create_promotion, update_promotion, delete_promotion - delete_holiday_price_bump_rule Does not grant: - The listing’s base nightly rate, which no v1 scope writes. `listings:write` covers listing CONTENT and `rates:write` covers per-night overrides, promotions and holiday bumps; changing the standing nightly price stays a host action in the Haven dashboard. - Creating a holiday price bump rule: only deletion is on the mobile write surface today, so only deletion is exposed - Changing what an existing booking was charged — see reservations:refund - Discount codes — see discounts:write, even though both land in the RATES sync domain Writes target the `RATES` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Stay rules | Scope | Tier | What it allows | | --- | --- | --- | | `stay-rules:read` | standard | Read your minimum-stay rules and your check-in and check-out day rules. | | `stay-rules:write` | standard | Create, change and remove minimum-stay rules and check-in and check-out day rules. | #### `stay-rules:read` Read your minimum-stay rules and your check-in and check-out day rules. Grants: - Date-scoped minimum-stay overrides - Weekly minimum-stay rules - Check-in / check-out day rules and the per-day blocks they expand into Does not grant: - The listing’s default check-in and check-out times — see listings:read - Availability — see calendar:read - Changing any of these — see stay-rules:write #### `stay-rules:write` Create, change and remove minimum-stay rules and check-in and check-out day rules. Grants: - create_min_stay_override, update_min_stay_override, delete_min_stay_override - create_weekly_min_stay_rule, update_weekly_min_stay_rule, delete_weekly_min_stay_rule, sync_weekly_min_stay_rules - set_check_in_out_blocks — named for what it actually dispatches (setCheckInOutBlocksBulk), not for the mobile command name create_check_in_out_rule, which misdescribes it Does not grant: - Deleting a legacy check-in / check-out RULE row: the mobile command delete_check_in_out_rule declares codes: ['property'] but its action takes only { ruleId }, so the declared code is dead and the command is not reachable through reference codes. Excluded from v1 rather than shipped broken - Blocking dates — see calendar:write - Pricing — see rates:write Writes target the `BOOKING_RULES` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Bookings | Scope | Tier | What it allows | | --- | --- | --- | | `reservations:read` | standard | Read your bookings and enquiries: dates, party size, status and what was charged. | | `reservations:write` | sensitive | Approve or decline requests to book, and answer guests who asked you to price an add-on. | | `reservations:cancel` | sensitive | Cancel a confirmed booking, which frees the dates and may refund the guest under your cancellation policy. | | `reservations:refund` | restricted | Send a refund on a booking, and change the amount a booking is charged. | #### `reservations:read` Read your bookings and enquiries: dates, party size, status and what was charged. Grants: - The booking list and each booking’s detail, by reference code - Type and origin (HAVEN, or the channel manager that owns it) - Dates, whole nights, and party size: guests, children, infants, pets - The pricing snapshot in integer minor units with a sibling currency code, or null where none was persisted - confirmedAt, cancelledAt and the cancellation reason - The host’s own note on a block - A summary of the listing the booking is on Does not grant: - The guest’s name or email address — both are returned as null without guests:read - The guest’s phone number, which is not on this surface at any scope - The message thread on the booking — see messaging:read - Card details, Stripe identifiers or identity-verification documents, none of which are on this surface at any scope - Approving, cancelling, refunding or repricing anything #### `reservations:write` Approve or decline requests to book, and answer guests who asked you to price an add-on. **Approving a request charges the guest and confirms the stay on your calendar.** Grants: - approve_booking - respond_bundle_quote Does not grant: - Cancelling a confirmed booking — see reservations:cancel - Refunding one or changing what it is charged — see reservations:refund - Creating a booking directly, which is not on the v1 surface - Reading the bookings it approves — see reservations:read Writes target the `RESERVATIONS` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). #### `reservations:cancel` Cancel a confirmed booking, which frees the dates and may refund the guest under your cancellation policy. **This cannot be undone, and it emails your guest.** Grants: - cancel_reservation Does not grant: - Issuing a refund outside the cancellation policy — see reservations:refund - Approving or repricing a booking - Blocking the dates it freed — see calendar:write Writes target the `RESERVATIONS` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). #### `reservations:refund` Send a refund on a booking, and change the amount a booking is charged. **This moves money out of your account. Every call is confirmed in two steps before anything is charged or refunded.** Grants: - issue_booking_refund and adjust_booking_price, both of which declare confirmation: 'required' and are only reachable through propose then commit Does not grant: - Payouts to your bank, your Stripe Connect account, or your Haven subscription — none of those are grantable at any tier - Any charge not attached to one of your bookings - Cancelling a booking — see reservations:cancel Writes target the `RESERVATIONS` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Discount codes | Scope | Tier | What it allows | | --- | --- | --- | | `discounts:read` | standard | Read your discount codes, what they take off, and what they apply to. | | `discounts:write` | standard | Change, enable, disable and delete your discount codes. | #### `discounts:read` Read your discount codes, what they take off, and what they apply to. Grants: - Every discount code on the account, its redeemable string, percentage, date window and enabled state - The listing or collection reference code it applies to Does not grant: - Redemption history or revenue attribution, which are not on the v1 surface - Validating a code against a specific stay - Creating or changing a code — see discounts:write #### `discounts:write` Change, enable, disable and delete your discount codes. Grants: - update_discount_code, toggle_discount_code, delete_discount_code Does not grant: - Creating a discount code. The action takes a nested target: { scope, propertyId \| propertyGroupId }, and the shared raw-id refusal scans top-level keys only, so today the only working call path is the raw integer id that refusal cannot see. Creation lands once the nested translator does; a public operation name is permanent, so it is not shipped broken first - Setting a fixed-amount discount, which the model does not carry Writes target the `RATES` source-of-truth domain. A listing whose channel manager owns that domain will refuse the write. See [Requests and responses](../03-conventions/01-requests-and-responses.md). ### Guidebook | Scope | Tier | What it allows | | --- | --- | --- | | `guidebook:read` | standard | Read the non-secret parts of your guidebooks: house rules, checkout steps, contact details, and how check-in works. | | `guidebook:secrets:read` | restricted | See the wifi password, gate code, directions and check-in instructions for your listings. | #### `guidebook:read` Read the non-secret parts of your guidebooks: house rules, checkout steps, contact details, and how check-in works. Grants: - Check-in and check-out times, and whether check-in is flexible - checkInMethod — the word "keypad" or "lockbox", never the code itself - Pet rules prose, checkout instructions and the checkout checklist flags - The listing’s contact email and phone number Does not grant: - The wifi network name and password, gate code, directions, check-in instructions or check-in photos — all six string fields come back empty and the photo array comes back empty without guidebook:secrets:read - The guidebook PIN (Property.guideAccessPin), which is a credential and is never returned at any scope - Smart-lock access codes, which are encrypted at rest and are not on this surface at any scope - Writing any guidebook field — no v1 scope writes the guidebook #### `guidebook:secrets:read` See the wifi password, gate code, directions and check-in instructions for your listings. **These are the details a stranger would need to get inside. Only grant this to an app that has to deliver them to your guests.** Grants: - Exactly the fields in PUBLIC_FIELD_GATES['guidebook:secrets:read'], which is asserted equal to LOCKED_GUIDE_STRING_FIELDS plus checkInPhotos, read from src/lib/guide-access/locked-guide-fields.ts at test time so the two can never diverge Does not grant: - Smart-lock access codes (EventAccessCode.encryptedCode), which are not on the public surface at any scope - The guidebook PIN, which is a credential - Writing any of these values — no v1 scope does - The guidebook itself: this scope only unlocks fields inside a payload guidebook:read returned, which is why it must be requested alongside it Must be requested alongside: `guidebook:read`. ### Messages | Scope | Tier | What it allows | | --- | --- | --- | | `messaging:read` | sensitive | Read your guest message threads and the messages in them. | | `messaging:send` | sensitive | Send messages to your guests, from you. | | `messaging:write` | standard | Clear the unread badge on your message threads. | #### `messaging:read` Read your guest message threads and the messages in them. **Message bodies are correspondence between you and your guests.** Grants: - The thread list: kind, the booking and listing each thread belongs to, last message time, a 200-character preview, which side sent it, and whether it is unread - Each message: sender, source (Haven, a channel manager, SMS, WhatsApp, email), body, attachment count, delivery status and timestamp Does not grant: - Sending anything — see messaging:send - Clearing the unread badge — see messaging:write - The guest’s name or email on a thread — see guests:read - Attachment bytes, which this surface counts but does not carry #### `messaging:send` Send messages to your guests, from you. **Messages reach a real person by email, SMS or WhatsApp and cannot be recalled.** Grants: - send_host_message, into an existing thread addressed by conversation code Does not grant: - Reading the thread it is writing into — see messaging:read - Starting a thread with anyone who is not already a party to one of your bookings or enquiries - Marketing sends, broadcasts, or messaging a guest after their stay is archived - Bypassing the per-thread throttle, which is enforced independently of the token’s own rate-limit budget #### `messaging:write` Clear the unread badge on your message threads. Grants: - mark_conversation_read Does not grant: - Sending anything — see messaging:send. Split from it deliberately, so an inbox mirror can keep your badge honest without gaining the ability to write to a guest - Deleting a thread or a message - Marking a GUEST’s side of a thread read, which is a different command on a different principal and is not on this surface ### Guests | Scope | Tier | What it allows | | --- | --- | --- | | `guests:read` | sensitive | See guest names and email addresses on your bookings and message threads. | #### `guests:read` See guest names and email addresses on your bookings and message threads. **This shares your guests’ personal details with a third party.** Grants: - guestDisplayName and guestEmail on a booking’s detail - guestDisplayName on a booking in a list - guestDisplayName on a message thread - Nothing else — the exact field list is PUBLIC_FIELD_GATES['guests:read'], and a test asserts the projection reads that table rather than an inline list Does not grant: - Any booking or thread the token could not already see: this permission only fills in fields on records another permission returned, which is why it must be requested alongside reservations:read - Guest phone numbers, which are not on the v1 surface at any scope - Payment instruments or identity-verification documents - Exporting a guest list, or any guest not attached to one of your bookings or enquiries - Message bodies — see messaging:read Must be requested alongside: `reservations:read`. ### Sync settings | Scope | Tier | What it allows | | --- | --- | --- | | `integrations:read` | standard | See which channel manager or property manager owns each listing, and which parts of it Haven will not let anyone else change. | #### `integrations:read` See which channel manager or property manager owns each listing, and which parts of it Haven will not let anyone else change. Grants: - Whether a listing is synced, and the display name of the partner that owns it - lockedDomains and writableDomains for the listing - Whether the listing follows a master in a linked calendar group, and that master’s title Does not grant: - The credentials for those connections - Connecting or disconnecting a channel manager - Changing which domains are locked, or unlocking a field - Any other app’s grants on this account ### Platform | Scope | Tier | What it allows | | --- | --- | --- | | `webhooks:manage` | standard | Register and manage the addresses this app uses to be told when something changes. | #### `webhooks:manage` Register and manage the addresses this app uses to be told when something changes. Grants: - Creating, listing, updating and deleting this app’s own webhook endpoints for this grant - Sending a test event, reading the delivery log, and replaying a failed delivery Does not grant: - Any other app’s endpoints, or endpoints on any other account - The app’s CLIENT-WIDE endpoint — the one whose `grantId` is null, which receives events for every host the app is connected to. A host access token may only ever touch endpoints whose `grantId` is its own grant; client-wide endpoints are managed from the developer console with client credentials. Without that rule this scope is a confused deputy: one host consenting would let the app repoint, disable, or read the delivery log of a channel carrying every other host’s events. - The contents of the events themselves, which stay gated by the data scopes: a webhook body carries reference codes and a timestamp, and the follow-up read is what needs reservations:read or calendar:read - Subscribing to an event class the grant holds no read scope for ## Planned scopes These are real and specified, but not built. Requesting one returns `invalid_scope` naming the release it is planned for, so you can build a roadmap against it rather than filing a ticket to ask. | Scope | Planned for | Why it is not in v1 | | --- | --- | --- | | `payouts:read` | v1.2 | The payout ledger has no positive-allowlist DTO and reaches Stripe Connect balance data that needs its own redaction pass. Declared now so a partner can plan against it. | | `reviews:read` | v1.2 | Haven-native and channel-imported reviews have no reconciled shape yet, and one of the two carries private text that is a secret on a par with a gate code. | | `reviews:write` | v1.2 | Blocked on reviews:read. | | `tasks:read` | v1.2 | The cleaning and turnover surface is mid-build and its rows are not yet addressed by reference code, so exposing them would publish integer primary keys. | | `tasks:write` | v1.2 | Blocked on tasks:read. | | `media:write` | v1.1 | Photo upload needs a signed-upload endpoint and a scan step, neither of which exists. Reordering and deleting existing photos rides on listings:write today. | | `listings:create` | v1.2 | Creating a listing changes what the account is billed for, so it needs a host-side confirmation the consent screen cannot give in advance. | ## Scopes no application will hold Declared so the boundary is documented rather than merely absent. An absent scope is indistinguishable from an oversight; a refused one is a decision. Requesting any of these returns `invalid_scope` with the reason below. | Scope | Category | Why | | --- | --- | --- | | `billing:read` | money | Subscription plan, invoices and card details. canManageBilling() returns false for every partner grant unconditionally, so there is nothing behind this scope to read. | | `billing:write` | money | Starting, changing or cancelling a paid plan spends the host’s money on a decision only the host can make. | | `payouts:write` | money | Moves the host’s money to a bank account. No third party gets this, at any tier, ever. | | `ads:read` | money | The advertising wallet is a prepaid balance; its reads are one query away from its writes and the surface is not worth splitting for a third party. | | `ads:write` | money | Spends a prepaid balance the host topped up. | | `team:read` | human-consent | Who holds a seat on the account is the account’s own access-control state, not business data. A partner that needs to know who acts for the account gets the effective role on account:read. | | `team:write` | human-consent | Inviting, removing or re-roling a teammate grants and revokes whole-account access, so it stays a deliberate human action — never delegated to an app. | | `workspaces:write` | human-consent | Workspace structure decides what every seat on the account can see. canManageWorkspaces() returns false for every partner grant unconditionally. | | `account:close` | human-consent | Irreversible, cancels billing, and revokes every other grant including this one. | | `devices:write` | different-principal | Push device registration writes the human’s own install row, keyed on their User.id and visible to nobody else. A partner acting for the account is not that human, and these are the only two commands that skip the workspace write backstop for a person. | | `guests:write` | different-principal | The nine guest-scoped commands act on the caller’s OWN stay — signing a rental agreement, answering a poll, inviting a co-guest — and deliberately skip the workspace write backstop because a guest writes nothing the account owns. A host-acting token must never reach them. A guest-side grant would be a different grant type with a different consent flow. | | `notifications:read` | app-shell | Haven’s own in-app alert feed. Every row is derived from a booking, message or listing a resource read already returns, so it is a second shape for the same facts. | | `notifications:write` | app-shell | Dismissing an alert changes what the host sees in Haven’s own UI and nothing else. It also returns a bare boolean rather than a result, so a failure would answer 200. | | `guide-chat:use` | unbounded-cost | The guidebook assistant is a language-model channel billed to Haven per token, with a documented rate-limit bypass of its own. A bearer credential on it is an uncapped spend channel pointed at Haven’s bill. | --- # 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. --- # Errors Reading this page once will remove most of the reasons you would otherwise write to support. ## Shape ```json { "success": false, "value": null, "error": { "detail": "insufficient_scope", "message": "This access token cannot perform \"create_price_override\": missing scope rates:write.", "requestId": "0f7e4c1a-2b8d-4a3f-9c11-6e5b0d7a2f34", "data": { "missingScope": "rates:write", "grantedScopes": ["account:read", "listings:read"], "operation": "create_price_override", "docsUrl": "https://www.bookwithhaven.com/developers/02-scopes/02-scope-reference", "reauthorizeUrl": "https://www.bookwithhaven.com/oauth/authorize?response_type=code&client_id=hvci_…" } } } ``` Branch on `detail`. It is a stable, machine-readable string and is part of the versioned contract. `message` is written for a human reading a log and may be reworded at any time; do not parse it or match on it. `data` carries per-code structured context. Its presence and shape depend on `detail`, and the codes that carry a documented `data` payload are listed below. ## Codes | `detail` | Status | Meaning | Retry? | | ----------------------- | ------ | --------------------------------------------------------------- | ------------------------------- | | `invalid_input` | 400 | Malformed body, bad parameter, unknown field | No, fix the request | | `invalid_token` | 401 | Token missing, expired, or revoked; see `data.reason` | Depends, see below | | `unauthenticated` | 401 | Not used on this surface; you will get `invalid_token` | No | | `insufficient_scope` | 403 | Valid token, missing permission | No, re-authorize with the scope | | `partner_suspended` | 403 | Your application is suspended | No, contact Haven | | `forbidden` | 403 | Permitted scope, but the host's role or the resource forbids it | No | | `not_found` | 404 | No such object, or not inside your grant | No | | `conflict` | 409 | Idempotency-key mismatch, or a source-of-truth lock | Depends, see below | | `rate_limited` | 429 | Budget exhausted | Yes, after `Retry-After` | | `internal_server_error` | 500 | Haven's fault | Yes, with backoff | ## The ones worth handling specifically **`insufficient_scope`** carries `missingScope` (one scope, the first one this call needed and your grant lacks), `grantedScopes`, `docsUrl` and a prebuilt `reauthorizeUrl`. No competitor names the missing scope; using it turns a support question into a redirect. See [Incremental authorization](../01-oauth/06-incremental-authorization.md). **`invalid_token`** arrives with a `WWW-Authenticate` header naming the reason and pointing at the protected-resource metadata document, and with `data.reason` carrying the same value in the body. The reason is drawn from a closed set, and each one has a different correct response — branch on it rather than refreshing blindly: | `data.reason` | What happened | What to do | | ----------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | `token_expired` | Normal. Access tokens are short-lived | Refresh. This is routine and not an error worth alerting on | | `token_revoked` | This access token was killed, but the grant may still be live | Refresh once. If that fails, re-authorize | | `grant_suspended` | The host's connection is paused | Stop. Prompt the host to reconnect from their Haven account | | `grant_revoked` | The host disconnected you | Stop, and delete your stored tokens. Send them through the authorization flow again | | `seat_lost` | The person who authorized you no longer has access to that account | Stop. Another person on the account must authorize you | | `unknown_token` | No `Authorization` header, or a credential Haven did not issue | Fix the request. Never retry this | Only `token_expired` and `token_revoked` are worth a refresh. Retrying the other four is how an integration produces a loop that generates 401s forever against an authorization that is genuinely gone. A bare `WWW-Authenticate: Bearer realm="Haven", resource_metadata="…"` with no `error` parameter means you sent no credential at all. The `resource_metadata` URL is on every challenge, so a client that has been configured with nothing but a base URL can discover the authorization server from one unauthenticated request. **`conflict`** means two different things and the `data.reason` field separates them. `domain_locked` means an external system owns that data for that listing, which is permanent until the host changes their setup, so do not retry. An idempotency conflict means you reused a key with a different payload, which is a bug in your client. **`rate_limited`** carries which limit you hit. See [Rate limits](07-rate-limits.md). ## Uncaught failures A `500` may occasionally arrive in a different shape: ```json { "error": "internal_error", "correlationId": "0f7e4c1a-…" } ``` That is Haven's outermost handler catching something the envelope never saw. Your client should tolerate both shapes on a 5xx rather than failing to parse. Report the `correlationId`. ## What to log Log `X-Haven-Request-Id` on every non-2xx, alongside the operation and the account. It is the single field that lets Haven find your exact request, and a support conversation that starts with one is usually a short conversation. ## Retrying Retry `429` after `Retry-After`, and `500`, `502`, `503` and `504` with exponential backoff. Add jitter of at least 25 percent; a fleet that retries on a synchronized schedule reconstructs the spike that caused the failure. Never retry a `400`, `403` or `404`. The request will not become valid. Retry a `409` only after determining which kind it is. For writes, retry with the **same** `Idempotency-Key`. That is what the key is for, and it is what makes a retry safe against an operation that already partly succeeded. --- # Reference codes Top-level objects are addressed by an eight-character lowercase hex string. ``` a1b2c3d4 a property 7f3e9c02 a reservation b41c9e07 an account ``` They appear in every payload, every webhook and every URL, and they are stable for the life of the object. Store them as your foreign key to Haven. ## Codes are not secrets A code is a compact identifier, not a capability. Do not build anything on the assumption that one is hard to guess. Authorization is always the account your token is bound to. A code belonging to another account returns `404`, not `403`, so the API cannot be used to test whether an object exists outside your grant. Two consequences worth taking seriously. Do not put a code in a URL you would not want shared, treating it as an unguessable link. And do not use one as a shared secret between systems. ## Child rows use integer ids Objects that only exist inside a parent are addressed by integer id: a price override on a listing, a promotion, a min-stay rule. ```json { "propertyCode": "a1b2c3d4", "priceOverrides": [ { "id": 88213, "date": "2026-09-14", "amountMinor": 42000 } ] } ``` The parent's code carries the authorization, and every mutation on a child re-checks it. A child id from another listing does not resolve inside yours. Two identifier vocabularies is a wart. It is the same wart the Haven mobile app carries, and the alternative, minting a code namespace for every child table, adds ceremony without adding a check. ## Do not construct or parse them Codes are derived, and Haven treats them as opaque strings. Sorting them is meaningless. Deriving one from another is not possible. Read them from responses; send them back verbatim. ## Casing and validation Always lowercase hex, always eight characters. Anything else is refused as `invalid_input` before the request reaches the database, so a malformed code costs you a fast 400 rather than a slow 404. --- # Pagination Every list endpoint paginates the same way. ``` GET /api/public/v1/reservations?limit=50 ``` ```json { "success": true, "value": { "items": [], "nextCursor": "eyJrIjoiMjAyNi0wOS0xNCIsImkiOjg4MjEzfQ" }, "error": null } ``` Pass `nextCursor` back as `cursor` for the following page. A `null` cursor means you have reached the end. ``` GET /api/public/v1/reservations?limit=50&cursor=eyJrIjoiMjAyNi0wOS0xNCIsImkiOjg4MjEzfQ ``` ## Cursors, not offsets Haven uses keyset cursors rather than page numbers. An offset-paginated list shifts under you when rows are inserted, so a caller walking it will miss records and see others twice. A keyset cursor names a position rather than a count, so insertions do not disturb a walk in progress. The cursor is opaque. Do not decode, construct or store one beyond the walk it belongs to. ## Sizes `limit` defaults to 25 and is capped at 100. A larger value is clamped rather than refused, so `limit=500` returns 100 without an error. That is friendly to a caller who guessed, and it means you should read what you got rather than assume you got what you asked for. ## The one that will bite you **`items.length < limit` does not mean you have reached the last page.** Haven filters rows your grant cannot see, and it filters them after the page is drawn. A page of 50 can legitimately return 3 items with a non-null cursor. A client that stops when a short page arrives will silently miss data, and this is the single most common bug in a first integration. Stop when `nextCursor` is `null`. Only then. ```python cursor = None while True: page = get("/reservations", limit=50, cursor=cursor) process(page["value"]["items"]) # may be empty cursor = page["value"]["nextCursor"] if cursor is None: break ``` ## No total count List responses carry no total. Counting the matching rows for every page is expensive on tables this size, and the number would be stale by the time you rendered it. If your interface needs a count, walk the list once and cache what you found. ## Incremental reads Where a resource supports it, `updated_since` is how you avoid re-walking a portfolio: ``` GET /api/public/v1/reservations?updated_since=2026-08-30T00:00:00Z ``` Hold the high-water mark from your last successful complete walk, not from the last page, so an interrupted run resumes correctly rather than skipping what it did not finish. Prefer webhooks over polling once they ship. Where you must poll, `updated_since` plus a sensible interval is far cheaper for both sides than a full walk. --- # Money and dates Two conventions that cause more integration bugs than anything else on this list. ## Money is an integer, in minor units ```json { "amountMinor": 42000, "currencyCode": "USD" } ``` That is 420.00 USD. Every monetary value on the API is an integer count of the currency's minor unit, alongside a sibling ISO 4217 code. Never a float. A nightly rate of 420.00 stored as a double is not reliably 420.00, and a total assembled from several of them drifts. The bug surfaces as a one-cent discrepancy in somebody's payout months later, which is an expensive way to learn it. The minor unit is not always two decimal places. JPY has none, so 42000 JPY is 42,000 yen. Use a currency library rather than dividing by 100. ## Do not compute totals Where Haven returns a total, use it. Do not assemble one from the line items. Totals involve fees, taxes, discounts and rounding rules that vary by jurisdiction and by host configuration, and reimplementing them means maintaining a second version of Haven's pricing logic that will diverge. If a total you need is missing from a payload, that is worth reporting. ## Two date formats, and they are not interchangeable **Stay dates are `YYYY-MM-DD`.** Check-in, check-out, a blocked night, the date a price override applies to. ```json { "startDate": "2026-09-14", "endDate": "2026-09-17" } ``` These are calendar dates in the listing's own timezone, not instants. A guest checking in on 14 September checks in on 14 September regardless of where your server runs. Sending `2026-09-14T00:00:00Z` for a listing in Los Angeles shifts the stay a day west, and the booking lands on the wrong night. Parse these as dates. In JavaScript, `new Date('2026-09-14')` gives you a UTC midnight instant that will format as 13 September in any timezone behind UTC. Keep the string, or use a date type that has no time component. **Timestamps are ISO 8601 with an offset**, and mean an actual moment: when a reservation was created, when a message was sent. ```json { "createdAt": "2026-08-31T14:02:11.000Z" } ``` Parse these as instants. Convert for display; store them as sent. ## Ranges are half-open `startDate` is inclusive, `endDate` is exclusive. A stay from `2026-09-14` to `2026-09-17` occupies the nights of the 14th, 15th and 16th, and the guest leaves on the morning of the 17th. That night is available. Half-open ranges are what make adjacent bookings expressible without an off-by-one: one stay ending on the 17th and another beginning on the 17th are consistent, not a double booking. ## Timezones Every listing carries an IANA timezone. Use it for anything a human at the property would recognize as a date: which night is blocked, whether a check-in is today. Do not use the host account's timezone for property-level reasoning. A host in Denver can own listings in Lisbon. --- # Idempotency Every write requires an `Idempotency-Key`. It is not optional and a request without one is refused. ``` POST /api/public/v1/operations/block_dates Idempotency-Key: 8f14e45f-ceea-467a-9b23-1f0c47a8d2e1 ``` Use a UUID, one per logical operation. Generate it before the first attempt and reuse it for every retry of that same operation. ## What it buys you A network timeout tells you nothing about whether the server acted. Without idempotency the only options are to retry and risk doing it twice, or not retry and risk not doing it at all. Neither is acceptable when the operation blocks a calendar or cancels a stay. With a key, retrying is safe. Haven records the outcome against the key and replays it. ## Semantics **Same key, same payload, operation completed.** You get the original response, byte for byte, including its status. The work is not repeated. **Same key, same payload, original still running.** You get `409`. Wait briefly and retry; the first attempt is in flight. **Same key, different payload or different operation.** You get `409` with a mismatch reason. This is a bug in your client: a key identifies one operation with one input, and reusing it for something else would mean Haven either performed the wrong write or returned the wrong answer. **New key.** Runs normally. Keys are scoped to your application and the account you are acting for. Another integration using the same UUID against the same host cannot collide with you. Records are retained 30 days. A retry beyond that executes as a fresh request. ## Getting it right Generate the key at the point you decide to act, not at the point you send. A key generated inside a retry loop is a new key on every attempt, which is the same as having none. ```python key = str(uuid.uuid4()) # once, outside the loop for attempt in range(5): response = post(url, json=body, headers={"Idempotency-Key": key}) if response.status_code < 500 and response.status_code != 429: break sleep(backoff(attempt)) ``` Persist the key alongside whatever queued the work, if the work survives a process restart. A worker that crashes mid-write and regenerates its key on restart will duplicate the operation. ## Replays are charged A replayed response counts against your rate limit. Idempotency is a correctness mechanism, not a free retry channel, and a client hammering one key would otherwise have an unmetered path to the origin. ## What is not idempotent Sending a message to a guest is not, and this is called out because the failure mode is embarrassing rather than merely wrong. If `messaging:send` returns a timeout, the message may have gone. Retrying with the same key is still the right move, since a completed send replays rather than repeats, but a send that failed after dispatch and before the record was written can produce a duplicate message to a real person. Prefer surfacing the ambiguity to the host over silently retrying. --- # Rate limits Limits are per application and per account, not per IP. Your whole fleet shares one budget for one host, and each host you are connected to has its own. ## Headers Every response carries your current position, not just a 429. ``` X-RateLimit-Limit-Read: 600 X-RateLimit-Remaining-Read: 573 X-RateLimit-Reset-Read: 1756654920 ``` The suffix is `Read` or `Write`, matching what the call you just made spends. A write carries the `Write` trio and never the `Read` one, so look for the suffix rather than a fixed header name. Two budgets are charged on every call — the per-account one and the client-wide one — but only one trio comes back: whichever of the two is closer to running out. Reporting the roomier number would be worse than reporting nothing, because the one you can see is then not the one that will stop you. Read `Remaining` and slow down before you hit zero. A client that only reacts to 429s is a client that generates them. ## When you exceed one ``` HTTP/1.1 429 Too Many Requests Retry-After: 12 X-RateLimit-Scope: account X-RateLimit-Reason: per-account write limit exceeded: 120 requests per 1m ``` ```json { "success": false, "value": null, "error": { "detail": "rate_limited", "message": "Rate limit exceeded.", "requestId": "0f7e4c1a-2b8d-…", "data": { "retryAfterSeconds": 12, "scope": "account", "window": "1m" } } } ``` `X-RateLimit-Scope` names which budget you exhausted, so you know whether to slow down against one host or across your whole integration. ## Honoring Retry-After Wait at least the number of seconds given, then add jitter of 25 percent or more. Jitter is not politeness. A fleet that all received the same `Retry-After` and all retry at exactly that moment reconstructs the burst that caused the limit, and the second wave is worse than the first because it arrives synchronized. ```python delay = retry_after * (1 + random.uniform(0, 0.25)) ``` ## Reads and writes are budgeted separately Writes are more expensive: each one fans out to a channel manager, an email, sometimes an SMS. Read budgets are correspondingly larger. Your tier is set at approval from your volume estimate and can be raised. If you are hitting limits during normal operation, that is a conversation rather than something to engineer around; write to info@bookwithhaven.com with your client id and the correlation id of a limited request. ## Staying under **Prefer webhooks to polling.** Once webhooks ship, an event tells you something changed and one read confirms it. Polling every listing every five minutes is the traffic shape limits exist to bound. **Use `updated_since`.** Re-walking a portfolio to find the one thing that changed is the expensive way to ask a cheap question. **Cache what does not move.** A listing's timezone, currency and capacity change rarely. The listing list is not a hot resource. **Batch where an endpoint offers it**, and do not fan out one request per row when a filtered list would do. **Back off on 5xx as well as 429.** A retry storm against an origin that is already struggling is how a degraded service becomes an outage. ## What is not limited here The two discovery documents are CDN-cached constants and are not rate limited. The token endpoint has its own limits, described in [The token endpoint](../01-oauth/03-token-endpoint.md); notably it limits by attempts rather than by successful mints, so a refresh bug slows you down rather than locking you out for a day. --- # Endpoint index > The resource endpoints are in active development. This page is the specification they implement, and the shapes here are what will ship. Sandbox availability is announced in the [Changelog](../07-reference/01-changelog.md). Every path is relative to `https://www.bookwithhaven.com/api/public/v1`. ## Reads | Method | Path | Scope | Returns | | ------ | ------------------------------------------ | ------------------- | -------------------------------------------------------- | | GET | `/me` | `account:read` | The account, the application, and what this grant may do | | GET | `/properties` | `listings:read` | Listings on the account, paginated | | GET | `/properties/{code}` | `listings:read` | One listing | | GET | `/properties/{code}/calendar` | `calendar:read` | Occupied and blocked spans over a date window | | GET | `/properties/{code}/price-overrides` | `rates:read` | Per-night price overrides | | GET | `/properties/{code}/promotions` | `rates:read` | Promotions | | GET | `/properties/{code}/holiday-price-bumps` | `rates:read` | Holiday price bump rules | | GET | `/properties/{code}/min-stay-overrides` | `stay-rules:read` | Per-date minimum stays | | GET | `/properties/{code}/weekly-min-stay-rules` | `stay-rules:read` | Weekly minimum stay rules | | GET | `/properties/{code}/check-in-out-rules` | `stay-rules:read` | Permitted check-in and check-out days | | GET | `/properties/{code}/sync-policy` | `integrations:read` | Which domains an external system owns | | GET | `/reservations` | `reservations:read` | Reservations, paginated and filterable | | GET | `/reservations/{code}` | `reservations:read` | One reservation | | GET | `/conversations` | `messaging:read` | Guest conversations, paginated | | GET | `/conversations/{code}/messages` | `messaging:read` | Messages in a conversation | | GET | `/discount-codes` | `discounts:read` | Discount codes on the account | | GET | `/guidebook/{code}` | `guidebook:read` | The guest guidebook for a listing | Guest identity on reservations and conversations requires `guests:read` in addition to the scope above. Without it, name, email and phone are `null` and the rest of the payload is unchanged. Arrival secrets in the guidebook require `guidebook:secrets:read`. ## Writes One endpoint. | Method | Path | | ------ | ------------------------- | | POST | `/operations/{operation}` | See [Operations](03-operations.md) for the catalog. ## MCP One endpoint, outside `/v1` because it is a JSON-RPC surface rather than a versioned REST one. | Method | Path | | ------ | ----------------- | | POST | `/api/public/mcp` | Every write operation, exposed as a Model Context Protocol tool and dispatched through the same code as the REST write above. See [MCP endpoint](04-mcp.md). ## Filters and windows List endpoints accept `limit` and `cursor` ([Pagination](../03-conventions/04-pagination.md)). Calendar and rate collections are read over a date window with `start_date` and `end_date`, both `YYYY-MM-DD`. The window is capped at 400 days; a wider request is clamped and the served window is echoed in the response. Reservations additionally accept `property_code`, `status`, `updated_since`, and `start_date` and `end_date` bounding the stay rather than the record. A bounded query is required on `/reservations`: pass at least one of `property_code`, `updated_since`, or a date range. An unbounded request for every reservation an account has ever taken is refused. It is the query that turns a public API into a full-table-scan service, and the constraint costs one parameter. ## Conditional requests Not supported in v1. Haven's data-version validator costs the same database work as serving the payload, so a 304 would save bandwidth and no compute. Use `updated_since`, and webhooks once they ship. --- # Account > In active development. This is the specification. ## GET /me The first call any integration should make, and the one to repeat after any authorization change. It answers whose account you are holding, what you may do with it, and where your rate limit stands. Requires `account:read`, which is on every grant automatically. It is the one scope you cannot decline, because an application that cannot tell which account it is acting for cannot behave correctly. ```bash curl https://www.bookwithhaven.com/api/public/v1/me \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "User-Agent: YourProduct/1.2.0 (you@example.com)" ``` ```json { "success": true, "value": { "account": { "code": "b41c9e07", "displayName": "Cascade Rentals", "timezone": "America/Los_Angeles", "currencyCode": "USD" }, "application": { "clientId": "hvci_your_client", "name": "Your Product", "sandbox": false }, "connection": { "id": "hvgr_9c21f0", "connectedAt": "2026-08-31T14:02:11.000Z", "lastUsedAt": "2026-08-31T18:44:02.000Z" }, "scopes": [ "account:read", "listings:read", "calendar:read", "calendar:write" ], "context": { "kind": "own-default", "role": "OWNER", "canWrite": true }, "rateLimit": { "readRemaining": 573, "writeRemaining": 118, "resetAt": "2026-08-31T18:45:00.000Z" } }, "error": null } ``` ## Fields worth acting on **`scopes`** is what you actually hold right now, after intersection with the host's grant and your approved ceiling. It may be narrower than what you requested and narrower than what you held yesterday. Treat it as authoritative rather than trusting your own record of the authorization. **`context.canWrite`** tells you whether a write will be refused on role grounds before you compose one. A host who authorized you from a read-only seat will have `canWrite: false`, and every write scope you hold is inert. Surface this in your own interface rather than discovering it on first use. **`context.kind`** is one of `own-default`, `member` or `linked`. `linked` means the person who authorized you manages this account through an agency relationship rather than owning it, which is worth knowing because that relationship can be severed independently of anything you or the host do. **`application.sandbox`** is `true` while your client is in sandbox. The data behind this grant is seeded test data, not a real portfolio. ## What it does not return The name or email of the person who approved the connection. Any listing, reservation, guest or message. Billing state, subscription plan or payout balance. The other applications this host has connected. ## Using it Call it once at the start of a sync run rather than before every request; it costs a read against your budget like anything else. Re-read it after any `insufficient_scope` failure, after completing an incremental authorization, and on a schedule slow enough not to matter, so that a narrowing a host performed in your absence does not surprise you mid-run. --- # Operations (writes) > In active development. This is the specification. Every write is one endpoint. ``` POST /api/public/v1/operations/{operation} Authorization: Bearer hvat_... Idempotency-Key: Content-Type: application/json User-Agent: YourProduct/1.2.0 (you@example.com) ``` The body is the operation's input. The response is the standard envelope. ## Why one endpoint rather than REST verbs Thirty write routes are thirty places to forget the scope check, the source-of-truth lock, the read-only backstop or the idempotency ledger. One route is one place, and the four checks run in a fixed order before any operation body executes. It also means an operation name is a stable, machine-readable identifier for a capability, which is what lets the same catalog drive the REST surface and, later, an MCP server without a second implementation. ## The catalog Operation names are permanent once published. Each maps to the same internal action the Haven dashboard calls, so a write here fans out identically: channel manager push, guest notification, pricing sync. ### Calendar | Operation | Scope | Domain | | --------------- | ---------------- | -------------- | | `block_dates` | `calendar:write` | `AVAILABILITY` | | `unblock_dates` | `calendar:write` | `AVAILABILITY` | ### Rates | Operation | Scope | Domain | | --------------------------------- | ------------- | ------- | | `create_price_override` | `rates:write` | `RATES` | | `update_price_override` | `rates:write` | `RATES` | | `delete_price_override` | `rates:write` | `RATES` | | `clear_price_overrides_for_range` | `rates:write` | `RATES` | | `create_promotion` | `rates:write` | `RATES` | | `update_promotion` | `rates:write` | `RATES` | | `delete_promotion` | `rates:write` | `RATES` | | `delete_holiday_price_bump_rule` | `rates:write` | `RATES` | ### Stay rules | Operation | Scope | Domain | | ----------------------------- | ------------------ | --------------- | | `create_min_stay_override` | `stay-rules:write` | `BOOKING_RULES` | | `update_min_stay_override` | `stay-rules:write` | `BOOKING_RULES` | | `delete_min_stay_override` | `stay-rules:write` | `BOOKING_RULES` | | `create_weekly_min_stay_rule` | `stay-rules:write` | `BOOKING_RULES` | | `update_weekly_min_stay_rule` | `stay-rules:write` | `BOOKING_RULES` | | `delete_weekly_min_stay_rule` | `stay-rules:write` | `BOOKING_RULES` | | `sync_weekly_min_stay_rules` | `stay-rules:write` | `BOOKING_RULES` | | `set_check_in_out_blocks` | `stay-rules:write` | `BOOKING_RULES` | ### Listings | Operation | Scope | Domain | | -------------------------- | ------------------ | ------------------------------------- | | `update_listing_content` | `listings:write` | `PROPERTY_CONTENT`, `LISTING_CONTENT` | | `set_listing_market_state` | `listings:publish` | `PROPERTY_CONTENT` | ### Reservations | Operation | Scope | Confirmation | | ---------------------- | --------------------- | ------------ | | `approve_booking` | `reservations:write` | | | `respond_bundle_quote` | `reservations:write` | | | `cancel_reservation` | `reservations:cancel` | | | `issue_booking_refund` | `reservations:refund` | required | | `adjust_booking_price` | `reservations:refund` | required | ### Discounts | Operation | Scope | | ---------------------- | ----------------- | | `update_discount_code` | `discounts:write` | | `toggle_discount_code` | `discounts:write` | | `delete_discount_code` | `discounts:write` | ### Messaging | Operation | Scope | | ------------------------ | ----------------- | | `send_host_message` | `messaging:send` | | `mark_conversation_read` | `messaging:write` | ## Order of refusal Checks run cheapest first, and a request that fails an earlier one never reaches a later one. This matters because it means a rejected write never touches the database and never consumes an idempotency record. 1. Is the operation a real one? If not, `404`. 2. Does your token carry the scope it declares? If not, `403` with `insufficient_scope`. 3. May the host's role write at all? If not, `403`. 4. Does an external system own the domain for this listing? If so, `409` with `domain_locked`. 5. Claim the idempotency key. 6. Run the operation. ## Confirmation Two operations move money and require an extra step: `issue_booking_refund` and `adjust_booking_price`. Call the operation with `"confirm": false` to receive a description of exactly what would happen and a single-use confirmation token, then call again with that token to commit. A partner-initiated refund is the one class of write where a bug is unrecoverable, so it does not happen in one call. ## Codes in, ids out Operations take reference codes for top-level objects: `propertyCode`, `reservationCode`, `conversationCode`. Child rows are addressed by the integer id you read from the parent. Sending a raw internal id where a code is expected is refused rather than accepted. ## Not available to any application No operation reaches billing, subscriptions, payout destinations, the advertising wallet, team membership, workspace structure, or account deletion. These are not missing from the catalog; they are refused by scope, and the refusals are documented in the [Scope reference](../02-scopes/02-scope-reference.md). --- # MCP endpoint ``` POST https://www.bookwithhaven.com/api/public/mcp ``` A Model Context Protocol server that exposes every write operation as a tool. It is not a second API. Each `tools/call` is turned into a request against `/api/public/v1/operations/{operation}` and handed to the same dispatcher a REST client reaches, so the scope gate, the rate limits, the source-of-truth lock, the confirmation step and the idempotency ledger are the same code running once — there is no path here that routes around a REST limit, because there is no second path. The tool name is the operation id, verbatim: `block_dates` in an MCP client is `POST /operations/block_dates` in this documentation. See [Operations (writes)](03-operations.md) for what each one does. ## Connecting Authorization is the same OAuth 2.1 flow as the REST API — see [The authorization flow](../01-oauth/01-authorization-flow.md). Send the resulting `hvat_` access token as `Authorization: Bearer …` on every POST. A client with nothing but the URL can discover the rest. An unauthenticated request answers `401` with: ``` WWW-Authenticate: Bearer realm="Haven", resource_metadata="https://www.bookwithhaven.com/.well-known/oauth-protected-resource" ``` The MCP endpoint is its own protected resource, described at `/.well-known/oauth-protected-resource/api/public/mcp`, and the REST surface is described at `/.well-known/oauth-protected-resource/api/public/v1`. Each document names the resource it describes, so a client that checks the `resource` value against the server it is calling gets a match either way. ## What the server supports `initialize`, `ping`, `tools/list` and `tools/call`. Nothing else. It is **stateless**: no sessions, no `Mcp-Session-Id`, no SSE stream, and `GET` is not implemented. Every POST carries its own bearer and is answered on the same connection. Server-initiated messages — sampling, elicitation, roots — are therefore unavailable, and the server advertises no capability that would need them. JSON-RPC batches are refused. MCP removed them in the 2025-06-18 revision. `tools/list` returns only the tools your grant's scopes allow. A tool that appears in the list is one the scope gate will admit; a tool that does not appear is one it would refuse. It is worth calling again after any authorization change — a host can narrow a grant at any time, and the list is the fastest way to see it. ## Idempotency, which works differently here Every write on this API requires an `Idempotency-Key`, and MCP has no headers. So: - **If you can set `_meta`**, put the key at `params._meta["haven/idempotencyKey"]`. Your `arguments` then stay byte-identical to the REST request body. - **If you cannot** — which is the normal case for a model filling in a tool schema — pass `idempotencyKey` as an ordinary argument. It is declared on every tool and is stripped before the operation sees the body. - **If you pass neither**, one is derived from the operation, your grant and the arguments themselves. That last case has a consequence worth stating plainly: **two calls with identical arguments are treated as one call**, and the second returns the first's result rather than writing again. That is the safe default for an agent that may retry, and it is wrong if you genuinely mean to write twice — send an explicit `idempotencyKey` when you do. ## The two tools that move money `issue_booking_refund` and `adjust_booking_price` are two-step, here as everywhere else on this API. The first call is **always refused**. Its `structuredContent` carries `data.confirmationToken`. Call the tool again with the **same arguments** and that token — as `confirmationToken` in the arguments, or at `params._meta["haven/confirmationToken"]` — and it executes. Show the amount to a human between the two calls. That is what the step is for. The token is valid for five minutes and only for those exact arguments, so a proposal for one amount cannot be committed as another. If you let the server derive the idempotency key, the two calls land on the same key automatically because the arguments are the same. If you supply your own, **reuse it on the second call**; a new key invalidates the token. ## Errors A refusal comes back as a successful `tools/call` whose result carries `isError: true`, with the message in `content` and the details in `structuredContent`: ```json { "isError": true, "content": [ { "type": "text", "text": "This listing's rates are owned by …" } ], "structuredContent": { "status": 409, "code": "conflict", "message": "This listing's rates are owned by …", "data": { "lockedDomain": "RATES", "pmsOwner": "Guesty" } } } ``` That is deliberate: a refusal on this API is written to be acted on, and a model only sees it if it arrives as tool output. JSON-RPC errors are reserved for messages that were never processed at all — a malformed envelope, an unknown method, an unknown tool name. Read `code`, not `message`. The taxonomy is in [Errors](../03-conventions/02-errors.md). ## Reading There are no read tools. Reads are the REST API at `/api/public/v1`, and an agent should use them: fetch the listing or the reservation before changing it. See [Endpoint index](01-endpoint-index.md), and [Notes for coding agents](../07-reference/02-for-agents.md) for the mistakes that cost the most time. --- # Overview and events > Webhooks are in active development. This is the specification. A webhook tells you something changed. It does not tell you what the thing now is. ## Thin payloads, on purpose ```json { "id": "01JGQ7XN2M4T8V6R0KZC3PWA5E", "type": "reservation.confirmed", "occurredAt": "2026-08-31T14:02:11.000Z", "accountCode": "b41c9e07", "data": { "reservationCode": "7f3e9c02", "propertyCode": "a1b2c3d4" } } ``` Reference codes and a timestamp. No guest names, no door codes, no money. Three reasons. A fat payload is a second copy of data the scope system is carefully guarding, delivered over a channel with weaker guarantees. Deliveries arrive out of order, so a payload describing state is a payload that can be stale on arrival and wrong if you write it down. And a body carrying listing prose reliably trips web application firewalls, which is how a competitor lost 295 deliveries in a week with nothing in their logs to explain it. Treat an event as an instruction to re-read. The REST call is the record. ## Events | Event | Fires when | Scope required to subscribe | | ------------------------------- | ---------------------------------------- | --------------------------- | | `reservation.requested` | A booking request is made | `reservations:read` | | `reservation.confirmed` | A booking is confirmed | `reservations:read` | | `reservation.cancelled` | A booking is cancelled | `reservations:read` | | `reservation.dates_changed` | Stay dates move | `reservations:read` | | `reservation.refunded` | A refund is issued | `reservations:read` | | `calendar.availability_changed` | Blocks change on a listing | `calendar:read` | | `rates.changed` | Overrides, promotions or bumps change | `rates:read` | | `listing.created` | A listing is added | `listings:read` | | `listing.updated` | Listing content changes | `listings:read` | | `listing.market_state_changed` | Archived, hidden, or booking toggled | `listings:read` | | `message.received` | A guest sends a message | `messaging:read` | | `connection.scopes_changed` | The host changes what you may do | none | | `connection.revoked` | The host disconnects you | none | | `endpoint.verified` | Sent once when an endpoint is registered | none | You cannot subscribe to an event class you hold no read scope for. The subscription is a filter; scope is the authority. The two `connection.*` events need no scope and cannot be unsubscribed. Learning that a host disconnected you from a webhook rather than from a failed call an hour later is the difference between a clean stop and a queue of errors. ## Registering an endpoint An endpoint is https only. Haven resolves the host and refuses private address ranges, link-local addresses and loopback, and does not follow redirects. On registration Haven sends a signed `endpoint.verified` probe. Answer `2xx` within ten seconds and the endpoint goes active. Until then nothing is delivered, which means a misconfigured URL fails at setup rather than silently swallowing a month of events. An endpoint may be scoped to one connection or shared across every host connected to your application. A shared endpoint is managed from your developer console with client credentials, never with a host's access token, so one host's consent can never reach a channel carrying another host's events. ## Delivery Acknowledge fast. Return `2xx` as soon as you have durably queued the event, and do your work afterwards. Haven allows ten seconds; a handler that does the work inline will eventually exceed it under load, and the retry will arrive while the first attempt is still running. Failed deliveries retry on a published schedule with growing gaps, and a permanent `4xx` dead-letters immediately rather than retrying against an endpoint that is gone. Sustained failure disables the endpoint and emails your contact address. You can re-enable it yourself; you will not have to ask Haven to do it for you. ## Duplicates and ordering **Expect duplicates.** At-least-once delivery is what makes retries safe. The `id` field is a stable delivery identifier: record processed ids and drop repeats. **Expect disorder.** A retried event can arrive after a newer one. Never reconstruct state from event sequence. Re-read the resource and use what the API returns. ## What does not arrive on connection When a host first authorizes you, you get `endpoint.verified` and nothing else. Existing listings, reservations and conversations are not replayed as events. Backfill by walking the REST endpoints once, then rely on webhooks for changes. This trips up almost every integration against every platform in this category, so it is stated plainly rather than left to be discovered. ## Delivery log and replay Every delivery is recorded with its response code and body snippet, queryable from your developer console and by API, and any delivery can be replayed on demand. That is unusual: one major competitor cannot list past failures at all, and another requires an email to their support team to re-enable a disabled endpoint. Being able to see what Haven sent and ask for it again is meant to remove a whole category of support conversation. --- # Verifying signatures > Webhooks are in active development. This is the specification. Every delivery is signed. Verify before you act, and verify before you parse. An unverified webhook endpoint is an unauthenticated write API into your own system, and anyone who learns the URL can drive it. ## Headers ``` svix-id: 01JGQ7XN2M4T8V6R0KZC3PWA5E svix-timestamp: 1756654931 svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= ``` The scheme is Svix-compatible, so the off-the-shelf libraries work unmodified. Haven chose it rather than inventing one because a scheme partners already have a verified implementation of is a scheme partners get right. ## The algorithm Concatenate the id, the timestamp and the raw body with periods, HMAC-SHA256 it with your base64-decoded signing secret, and base64 the result. ``` signed_content = f"{svix_id}.{svix_timestamp}.{raw_body}" expected = base64(hmac_sha256(base64_decode(secret_without_prefix), signed_content)) ``` ```python import base64, hashlib, hmac, time def verify(secret: str, headers: dict, raw_body: bytes) -> bool: msg_id = headers["svix-id"] timestamp = headers["svix-timestamp"] signatures = headers["svix-signature"].split(" ") if abs(time.time() - int(timestamp)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for candidate in signatures: version, _, value = candidate.partition(",") if version != "v1": continue if hmac.compare_digest(value, expected): return True return False ``` ## Four things that go wrong **The raw body, not the parsed one.** Sign over the bytes you received. Re-serializing parsed JSON reorders keys and changes whitespace, and the signature will not match. In most frameworks this means capturing the body before any JSON middleware. **Constant-time comparison.** Use `hmac.compare_digest` or your language's equivalent. A `==` on the signature leaks it a byte at a time to anyone willing to measure. **The timestamp.** Reject anything more than five minutes from now. Without it a captured delivery is replayable forever, and the signature stays valid because the payload has not changed. **Multiple signatures.** The header can carry several space-separated values. Accept the delivery if any `v1` entry matches. This is not an edge case: it is how rotation works. ## Rotation During a rotation, deliveries carry two signatures: one under the new secret and one under the old. Both are valid until the overlap expires. That is why the loop above iterates rather than comparing against the first value. A verifier that checks only the first signature will fail every delivery the moment you rotate, which is precisely when you are least able to debug it. Rotate by minting a new secret, deploying it alongside the old one, confirming deliveries verify, then retiring the old. ## Responding Return `2xx` as soon as the event is durably stored. Anything else is a failure and will be retried. Do not return `2xx` after doing the work, if the work is slow. Do not return `4xx` for an event type you do not recognize: unknown types are a normal consequence of Haven adding events, and a `4xx` will dead-letter the delivery permanently. Ignore what you do not handle and acknowledge it. ## Testing Send a test event to any registered endpoint from your developer console. It is signed identically to a real delivery, so it exercises your verification path rather than bypassing it. --- # Sandbox and certification You do not wait for approval to start building. An application enters sandbox before anyone reviews it, and sandbox runs the real API. ## What sandbox is A client in `SANDBOX` completes the full OAuth flow, receives real tokens, and calls every endpoint its scopes allow. The only difference is which account it reaches: a seeded test account with listings, calendars, reservations, guests and conversations, rather than a real host's portfolio. Nothing is mocked and there is no second code path. The seeded account simply has no channel manager connected, no payment processor and no phone number, so the fan-out that a real write would trigger has nowhere to go. You exercise the same handlers a production call does. Sandbox access tokens live twenty-four hours rather than one, because re-authenticating hourly at a REPL is a bad way to spend an afternoon. ## What to build against it Everything. The authorization flow including refresh and rotation. Every read your product needs. Every write, including the ones that would be expensive to get wrong. Webhook delivery and signature verification. Your error handling, particularly the 429 and `insufficient_scope` paths, which are the two most integrations get wrong and the two easiest to exercise deliberately. The seeded account is yours to modify. Break it. ## Certification Moving to production is a short technical review rather than a second queue. Haven checks five things, all of which you can verify yourself against sandbox first. **Signature verification.** Your endpoint rejects a delivery with a tampered body and accepts one carrying two valid signatures. Send a test event, then send yourself a modified copy, and confirm the second is refused. **Retry-After.** A 429 results in a wait of at least the given interval, plus jitter. Drive yourself into a limit deliberately and watch what your client does. **User-Agent.** Every request names your product and a contact address. **Idempotency.** Every write carries a key, and the key survives a retry rather than being regenerated inside the loop. **Re-read after webhook.** Your handler treats the event as a signal and fetches the resource, rather than writing the payload into your database. Each of these is a support ticket that will not be filed. That is the whole reason the list is short and specific rather than a general assurance of quality. ## Going live On certification your client moves to `APPROVED` and real hosts can authorize it. Sandbox grants do not become production grants. A grant records whether it was created in sandbox, so promoting your client never silently repoints an existing token at real data. Your first production connection is a real host completing the flow. Roll out gradually. A week with a handful of consenting hosts surfaces the problems that only appear against real portfolios: a listing with sixty photos, a calendar with a linked group, a host whose channel manager owns availability on half their listings. All of these exist in production and none are convenient to seed. ## Requesting sandbox Sandbox credentials are issued when your application is approved for development. Write to info@bookwithhaven.com; see [Getting access](../00-start-here/01-getting-access.md) for what to include. --- # Support and status ## Getting help info@bookwithhaven.com. Include the **correlation id** of a request that demonstrates the problem. It arrives on every response as `X-Haven-Request-Id`, and on failures as `error.requestId`. It identifies your exact request in Haven's logs, and its presence usually turns a day of back-and-forth into a single reply. Also include your `client_id`, the operation or endpoint, and roughly when it happened. Do not include an access token, a refresh token or a client secret. Support never needs one, and sending one means rotating it. ## Log this Log `X-Haven-Request-Id` on every non-2xx response, next to the account and the operation. Integrations that do this get answers quickly. Integrations that do not end up describing symptoms. Also worth keeping: the `scope` value from your most recent token response, and the `scopes` array from your last `/me`. When something starts returning 403, the first question is what your token actually holds, and the second is when it changed. ## Reaching you Your application carries two addresses and they are used differently. The **support address** is where routine correspondence goes and may be a shared inbox. The **security address** is for disclosures and incidents, and should reach someone who can act within hours. If Haven has to suspend your application at two in the morning because of a credential leak, this is the address that gets the call. A monitored alias beats a person who might be on holiday. ## When Haven contacts you **Deprecation.** Breaking changes are announced with a `Deprecation` header on affected responses and a `Sunset` date, plus an entry in the [Changelog](../07-reference/01-changelog.md) and an email. The header arrives long before the date. **Abnormal traffic.** A sudden change in volume or error rate gets an email before anything blunter happens. This is the reason `User-Agent` is enforced. **Suspension.** Rare and reversible, used for incident response. Your calls return `403` with `partner_suspended` and the token endpoint returns `invalid_client`. ## Status and incidents Haven does not yet publish a status page for the public API. Until it does, info@bookwithhaven.com is the channel for both directions, and an incident affecting partner traffic is emailed to security contacts. ## What to monitor on your side Four signals catch most problems early. Your 429 rate, which tells you whether you are near a ceiling before you hit it. Your `invalid_grant` rate on refresh, which is hosts disconnecting and is normal at a low level and a problem in a spike. Webhook signature failures, which should be zero and mean either a rotation you did not handle or something sending you forged deliveries. And the age of your oldest unprocessed webhook, which is the earliest indication that your handler is falling behind. --- # Changelog Changes to the public API, newest first. Additive changes ship without notice. Breaking changes are announced here, carry `Deprecation` and `Sunset` headers on affected responses, and are emailed to every application's security contact. ## What counts as breaking Removing an endpoint, a field or an operation. Changing a field's type. Making an optional request field required. Removing a value from a closed enumeration. Narrowing what a scope grants. ## What does not Adding an endpoint, an optional field, an operation, a scope, a webhook event, or a value to an open enumeration. Reordering fields. Rewording an `error.message`. **Your client must tolerate all of these.** Ignore unknown fields, ignore unknown webhook event types rather than rejecting them, and never switch exhaustively over a value the API returns unless it is documented as closed. Adding a booking status should not break your integration, and if it would, that is a bug worth fixing before it costs you an outage. ## Versioning The major version is in the path: `/api/public/v1`. It changes only for a change that cannot be made additively, and v1 will keep working alongside any successor for a published period rather than being switched off. Every response carries `X-Haven-Api-Version`. --- ## Unreleased Everything below is built. Nothing here is callable in production until Haven finishes provisioning and approves your application — see [Getting access](../00-start-here/01-getting-access.md). Callable: - **The OAuth 2.1 authorization server.** Both discovery documents, `/oauth/authorize` with the host consent screen, and `/api/public/oauth/token`, `/revoke` and `/introspect`. PKCE with `S256` is mandatory for every client, confidential ones included. Refresh tokens rotate, with a sixty-second grace window for concurrent refreshes. - **The resource endpoints** under `/api/public/v1`. Eighteen reads. - **The write surface.** `POST /operations/{operation}` and the `GET /operations` catalogue, thirty operations. `issue_booking_refund` and `adjust_booking_price` are two-step: the first call is refused and returns a confirmation token. - **The [MCP endpoint](../04-api/04-mcp.md)** at `/api/public/mcp`. Every write operation as a Model Context Protocol tool, dispatched through the same code as the REST write — same scopes, same limits, same idempotency ledger. - **`/connected-apps`**, where a host reviews and revokes the applications connected to their account. - The scope registry. The [Scope reference](../02-scopes/02-scope-reference.md) is generated from the registry the server enforces. - The error taxonomy, including `insufficient_scope` with the missing scope named. - The approval gate. No credential functions until Haven staff move an application to `SANDBOX` or `APPROVED`. Not yet callable: - **Webhook delivery.** Planned for v1.1; the [Webhooks](../05-webhooks/01-overview.md) pages describe the design rather than a live surface. Until it ships, poll with `updated_since`. - **The developer console.** Applications are handled by email in the interim. ## 2026-08-31 Public API documentation published, separated from Haven's first-party mobile API documentation. --- # Notes for coding agents If you are a model building an integration against this API, this page is the short version. It assumes you will read a few pages rather than all of them and tries to name the things that are expensive to get wrong. ## If your client speaks MCP, use it ``` POST https://www.bookwithhaven.com/api/public/mcp ``` Every write operation as a tool, dispatched through the same code as the REST write — same scopes, same limits, same idempotency ledger, same two-step confirmation on the two tools that move money. See [MCP endpoint](../04-api/04-mcp.md), and read the idempotency section there before you retry anything. Reads are still REST. Fetch the listing or the reservation before you change it. ## Load the whole contract in one request ``` GET https://www.bookwithhaven.com/developers/llms.txt ``` Every page, concatenated, each preceded by the path it came from. Individual pages are also fetchable as raw Markdown under `/developer-docs/.md`. ## The eight things that cause the most rework **A token is bound to one account.** Fixed when the host approved you. Not a header, not switchable. An agency with forty clients is forty authorizations. **Read the `scope` field on every token response.** Effective permission is recomputed per request and can be narrower than what you asked for. Do not cache authorization decisions. **`items.length < limit` does not mean the last page.** Rows you cannot see are filtered after the page is drawn. Stop only when `nextCursor` is `null`. This is the single most common bug in a first integration. **Stay dates are `YYYY-MM-DD` and are not instants.** `new Date('2026-09-14')` produces a UTC midnight that formats as the 13th in any timezone behind UTC, which moves the booking a night. Keep the string. **Money is an integer in minor units** with a sibling currency code. Never a float. Do not compute totals; use the ones returned. **A granted scope is not sufficient for a write.** If the host's channel manager owns that domain for that listing, the write is refused with `domain_locked`. Read `lockedDomains` first. **Every write needs an `Idempotency-Key`,** generated once per logical operation and reused across retries. Generating it inside the retry loop defeats the mechanism entirely. **Webhooks are signals, not state.** Thin payloads, at-least-once, out of order. Re-read the resource; never reconstruct state from event sequence. ## Errors are structured, so branch on them Switch on `error.detail`, never on `error.message`. `insufficient_scope` carries `missingScope` and a prebuilt `reauthorizeUrl`. `conflict` carries a `reason` distinguishing a permanent source-of-truth lock from an idempotency mistake. Retry `429` after `Retry-After` with jitter, and `5xx` with backoff. Never retry `400`, `403` or `404`. ## Ordering Call `/me` first, always. It tells you the account, the effective scopes and whether writes will be permitted at all. Re-read it after any authorization change and after any `insufficient_scope`. Backfill by walking REST once. Existing data is not replayed as webhooks when a host connects. ## Do not Do not construct reference codes, or assume they are unguessable, or sort by them. Do not send `X-Haven-Client-Id` or `X-Haven-Workspace-Context`. Both are rejected with a `400`, and both attempt to assert an identity claim a partner token has no standing to make. Do not omit `User-Agent`. It is enforced and must carry a contact address. Do not poll where a webhook would do, and do not retry without jitter. A synchronized fleet reconstructs the burst that caused the limit. ## Scope selection Ask for the narrowest set that does the job. Narrow requests are approved faster, and [Incremental authorization](../01-oauth/06-incremental-authorization.md) means widening later costs one redirect rather than a new application. Note in particular that `guests:read` is separate from `reservations:read`. If your product does not need the guest's name and email, do not request it; the reservation payload is otherwise identical.