<!-- Beyond API — full documentation in one file. -->
<!-- Canonical site: https://developers.beyondpricing.com/ -->
<!-- Relative links are relative to that site root. -->
<!-- Generated from the MkDocs sources; do not edit by hand. -->

---

---
description: >-
  Official Beyond API developer documentation — integrate programmatically with
  Beyond's revenue management platform for short-term rentals: listings, pricing
  calendars, market insights, and webhooks over OAuth2 and JSON:API.
---

# Beyond API Documentation

The Beyond API enables programmatic integration with Beyond's revenue management platform for short-term vacation rentals. There are two ways to use it — pick the path that matches you.

## Choose Your Path

| You are… | Start here |
|----------|------------|
| A **partner or company** integrating many Beyond users on a secure backend — white-label products, multi-tenant platforms, server-to-server jobs. Uses OAuth2 client credentials, optional `user_id` / `credential_id` narrowing, and IP allowlists. | [For Partners](getting-started/partners.md) |
| An **individual Beyond user** automating tasks on your own listings — scripts, cron jobs, CLIs, agents. Uses a personal access token (PAT). | [For Personal Users](getting-started/personal-users.md) |

Not sure which applies? If you authenticate with a client secret on a backend you control and manage other people's Beyond accounts, you are a **partner**. If you are automating your own Beyond account, you are a **personal user**. The [Authentication](getting-started/authentication.md) overview compares every mechanism side by side.

!!! tip "Migrating from the legacy API?"
    If you previously used the legacy API at `api.beyondpricing.com/api` with
    a `Token` header, see
    [Migrating from the legacy API](guides/migration-from-legacy-api.md) for a
    side-by-side endpoint mapping.

## What You Can Do

The Beyond API v1 provides programmatic access to:

- **[Listings](endpoints/listings.md)** -- retrieve and manage property listings with pricing data
- **[Calendar](endpoints/calendar.md)** -- access availability and pricing calendars
- **[Users](endpoints/users.md)** -- create and manage user accounts (partner integrations)
- **[Accounts](endpoints/managed-accounts.md)** -- connect channel accounts (PMS, Airbnb, etc.)
- **[Webhooks](webhooks/index.md)** -- receive signed, real-time event notifications instead of polling (partners)

## Shared Reference

- **[Authentication](getting-started/authentication.md)** -- OAuth2 client credentials for partners, personal access tokens (`bpat_…`) for individual users, following [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)
- **[JSON:API Format](guides/jsonapi.md)** -- consistent request and response structure per the [JSON:API specification](https://jsonapi.org/) (v1.1)
- **[Rate Limiting](guides/rate-limiting.md)** -- transparent limits with response headers for client-side throttling
- **[Error Handling](guides/error-handling.md)** -- standardized error responses with actionable details
- **[Versioning](guides/versioning.md)** -- URL-based versioning with a clear deprecation policy
- **API Reference** -- interactive [Swagger UI](/api/v1/docs/) and [ReDoc](/api/v1/redoc/), auto-generated via OpenAPI
- **[Release Notes](release-notes.md)** -- changelog of API updates and improvements

## AI-Driven Client Implementation

When implementing API clients with AI tools, provide both schema-level and narrative docs context:

- **OpenAPI Specification**: [schema endpoint](/api/v1/schema/) for precise endpoint contracts, field types, and request/response structures
- **Full Documentation (Markdown)**: [single markdown document](/full-documentation.md) for authentication, guides, behavior details, and endpoint usage examples in one file

## Base URL

All API requests use the base URL of your Beyond Developers deployment (e.g., `https://developers.beyondpricing.com`). Throughout this documentation, curl examples use `$BASE_URL` as a placeholder:

```bash
export BASE_URL=https://developers.beyondpricing.com
```

## Support

For questions about your API integration:

- **Email**: support@beyondpricing.com

---

---
description: >-
  Get started with the Beyond API: choose the partner or personal-user path, obtain credentials, and make your first request.
---

## Getting Started

Choose the path that matches who you are. Beyond Pricing supports two audiences: **partners** integrating many Beyond users on a secure backend, and **personal users** automating their own account.

Start with **[Authentication](authentication.md)** for diagrams and guidance on partner versus personal mechanisms (client credentials and PATs).

### Choose Your Path

#### [For Partners](partners.md)

Use this path if you are a company or large customer integrating many Beyond users on a backend you control — white-label products, multi-tenant platforms, cron jobs, and other server-to-server integrations.

- Client type: confidential
- Default grant: client credentials
- Optional narrowing: user-scoped tokens via `user_id`, optionally further scoped with `credential_id`
- The [For Partners](partners.md) page covers the full end-to-end flow, from token setup to listing activation

#### [For Personal Users](personal-users.md)

Use this path if you are an existing Beyond user automating tasks on your own listings with a CLI, script, or agent.

- App mode: personal
- Authentication: a [personal access token](authentication.md#personal-access-token-pat) (PAT) created from the Beyond dashboard
- Bound to one Beyond login; no client secret to manage
- The [For Personal Users](personal-users.md) page covers the full end-to-end flow, from PAT creation to automation

### Common Prerequisites

Before you begin, you need:

1. Beyond-provisioned OAuth2 credentials or an application created by your operator.
2. The correct base URL for your environment.
3. An HTTP client capable of making REST API calls.

For help provisioning credentials, contact `support@beyondpricing.com`.

### Common Endpoints

| Component | URL |
|-----------|-----|
| API Endpoints | `/api/v1/*` |
| Token Endpoint | `/o/token/` |
| Swagger UI | [`/api/v1/docs/`](/api/v1/docs/) |
| ReDoc | [`/api/v1/redoc/`](/api/v1/redoc/) |

---

---
description: >-
  Authenticate with the Beyond API using OAuth2 client credentials for partners or personal access tokens for individual users, following RFC 9700.
---

## Authentication

This page is the overview for all supported ways to call the Beyond API (`/api/v1/*`). Step-by-step curl examples live in the audience guides linked below.

New here? Start at [For Partners](partners.md) or [For Personal Users](personal-users.md), then return here when you need the full mechanism reference.

- [For Partners](partners.md) — confidential clients and client credentials
- [For Personal Users](personal-users.md) — personal access tokens

### Choose The Right Mechanism


| Who you are                                                                 | What you should use                                                                             | Why                                                                                                                                                                                                                                                              |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Partner or company** integrating many customers on a secure backend       | **OAuth2 client credentials** with a **confidential** client                                    | Strongest option: client secret never ships to end-user devices, machine-to-machine access, optional per-user and per-credential narrowing with `user_id` and `credential_id`.                                                                                   |
| **Individual Beyond user** automating your own account (CLI, script, agent) | **Personal access token (PAT)** | A PAT is a long-lived, credential-scoped secret you create yourself from the Beyond dashboard. No client secret to manage, no browser step at runtime; ideal for non-interactive automation on your own account. |


Partners should **not** rely on PATs for production integrations: PATs exist only for personal automation applications, not partner multi-tenant apps.

For the personal access token walkthrough, use [For Personal Users](personal-users.md). For partner token requests with `user_id` / `credential_id`, use [For Partners](partners.md).

On every `/api/v1/`* request, send the access token or PAT in the header `Authorization: Bearer <token>`.

### OAuth2: Client Credentials (Confidential Partner)

Typical path: your backend exchanges `client_id` and `client_secret` for an access token, then calls `/api/v1/`*. Optional `user_id` and `credential_id` narrow the token (see [For Partners](partners.md)).

```mermaid
sequenceDiagram
    participant Partner as PartnerBackend
    participant Token as OAuthTokenEndpoint
    participant API as PartnersAPI

    Partner->>Token: POST /o/token/ client_credentials
    Note right of Partner: client_id, client_secret, scope optional user_id credential_id
    Token-->>Partner: access_token, expires_in, token_type Bearer
    Partner->>API: GET /api/v1/... with Authorization Bearer
    API-->>Partner: JSON API response
```



### Shared OAuth2 Rules

The Beyond API follows [RFC 9700 - OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/rfc9700).

#### Token Lifetimes

- OAuth2 access tokens (client credentials) expire after 1 hour.
- Personal access tokens do not expire until revoked.

#### Grant Restrictions


| Grant Type           | Status   | Notes                           |
| -------------------- | -------- | ------------------------------- |
| Client Credentials   | Allowed  | For partner server integrations |
| Implicit             | Disabled | Rejected                        |
| Password             | Disabled | Rejected                        |


#### User-Scoped Tokens

Server integrations can ask `/o/token/` for either an application-level token or a user-scoped token:

- **Application-level token**: omit `user_id`. The token acts for the OAuth application and can access all users owned by that application, subject to the scopes granted on the token.
- **User-scoped token**: include `user_id`. The token is bound to that one user. You may also include `credential_id` to apply one Beyond login's visibility and permissions; without it, Beyond uses the user's primary credential.

If Beyond enables `require_user_scoped_tokens` for your application, user-level resource scopes cannot be used on an application-level token. You must include `user_id` for those scopes. App-level scopes and cross-tier scopes still work without `user_id`.

#### Scope Tiers

All requested scopes must first be enabled for your OAuth application. After that, token tier rules apply:


| Tier       | Scopes                                                                                                                  | Application-level token, no `user_id`                                                                        | User-scoped token, with `user_id`                                                                             |
| ---------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| App-level  | `user:write`, `webhooks:write`                                                                                          | Allowed. Operates on the application as a whole.                                                             | Rejected. App-level scopes cannot be combined with `user_id`.                                                 |
| User-level | `listings:read`, `listings:write`, `reservations:read`, `accounts:read`, `insights:read`, `compsets:read`, `neyoba:ask` | Allowed only when `require_user_scoped_tokens` is disabled for the application. Rejected when it is enabled. | Allowed. Access is limited to the bound user, and to the selected credential when `credential_id` is present. |
| Cross-tier | `user:read`, `webhooks:read`                                                                                            | Allowed. Returns users owned by the application, and every webhook event delivered to it.                    | Allowed. Returns only the bound user, and only that user's webhook events.                                    |


#### Scope Reference


| Scope               | Tier       | Description                   |
| ------------------- | ---------- | ----------------------------- |
| `user:write`        | App-level  | Create and modify users       |
| `webhooks:write`    | App-level  | Modify webhook configuration  |
| `user:read`         | Cross-tier | Read user information         |
| `webhooks:read`     | Cross-tier | Read delivered webhook events |
| `listings:read`     | User-level | Read listing data             |
| `listings:write`    | User-level | Modify listings               |
| `reservations:read` | User-level | Read reservation data         |
| `accounts:read`     | User-level | Read account information      |
| `compsets:read`     | User-level | Read competitive set data     |
| `insights:read`     | User-level | Read market insights          |
| `neyoba:ask`        | User-level | Ask Neyoba, Beyond's AI agent |


#### IP Allowlist

When configured for an application, IP allowlists are enforced on both `/o/`* and `/api/v1/`*.

### Personal Access Token (PAT)

PATs are **not** an OAuth2 grant. Beyond users can create and manage their own PATs from the Beyond dashboard user settings when they need a long-lived credential for personal automation. Beyond shows the secret exactly once at creation time; after that, each request uses `Authorization: Bearer` with the PAT. Store it like any other high-value credential. A PAT inherits the permissions of the Beyond login (credential) it was issued for: what listings and accounts the token can read and write follows that credential's configuration on the [Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials), and permission changes there apply to the token immediately. IP restrictions, when Beyond has configured them for the app, are enforced the same way as for OAuth2 access tokens.

```mermaid
sequenceDiagram
    participant Client as AutomationClient
    participant API as PartnersAPI

    Client->>API: Authorized request with Bearer PAT
    API-->>Client: JSON API response
```



Use your issued cleartext value in the `Authorization` header. Personal access tokens always start with the `bpat_` prefix.

#### PAT Rules (Summary)

- **Personal automation only** — PATs are for personal automation apps (one Beyond user), not for partner-style multi-customer integrations.
- **One login** — A PAT only ever acts as the Beyond login (credential) it was issued for. It cannot switch to another login under the same user.
- **Self-service creation** — PATs are created from the Beyond dashboard, not minted through `/o/token/`.
- **Permissions follow the credential** — A PAT acts with the permissions of its credential. Manage what the token can read and write through that credential on the [Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials); changes apply immediately.
- **No OAuth minting** — PATs are not created or refreshed through `/o/token/`. They are created and revoked from the Beyond dashboard.
- **Beyond Pro required** — A PAT needs an active Beyond Pro trial or subscription. Beyond revokes a user's tokens when their Pro trial or subscription ends.

---

---
description: >-
  Partner integration guide for the Beyond API: OAuth2 client credentials, managing many Beyond users, connecting channel accounts, and enabling listings.
---

## For Partners

You are a **partner** if you are a company or large customer integrating many
Beyond users on a backend you control — white-label products, multi-tenant
platforms, or server-to-server automation. Partners authenticate with **OAuth2
client credentials** on a confidential client, can narrow access to a single
Beyond user or login with `user_id` / `credential_id`, and are typically
protected by an IP allowlist.

This is the path for trusted backend services that can store a client secret
securely. This page is the end-to-end path: how authentication and token
scoping work, then the onboarding flow from creating a user to activating a
listing.

If instead you are an individual Beyond user automating your own listings, see
[For Personal Users](personal-users.md).

### When To Use This Path

Choose this profile if your integration:

- Runs on your servers, workers, or scheduled jobs.
- Needs machine-to-machine access without an end-user browser step.
- May optionally narrow access to one Beyond user at a time with `user_id`.
- May optionally narrow a user-scoped token further with `credential_id`.

### Recommended Configuration


| Setting               | Value                           |
| --------------------- | ------------------------------- |
| App mode              | `partner`                       |
| Client type           | `confidential`                  |
| Primary grant         | `client_credentials`            |
| Access token lifetime | 1 hour                          |


For a comparison with personal automation flows and personal access tokens,
read the [Authentication](authentication.md) overview first.

### Get An Access Token

Confidential clients use the client credentials grant at `/o/token/` and then
send the access token on `/api/v1/*` requests.

```mermaid
sequenceDiagram
    participant Partner as PartnerBackend
    participant Token as OAuthTokenEndpoint
    participant API as PartnersAPI

    Partner->>Token: POST /o/token/ grant_type=client_credentials
    Note right of Partner: client_id, client_secret, scope optional user_id credential_id
    Token-->>Partner: access_token, expires_in, token_type Bearer
    Partner->>API: GET /api/v1/... Authorization Bearer access_token
    API-->>Partner: JSON API response
```



```bash
curl -X POST $BASE_URL/o/token/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=listings:read user:read"
```

**Response**

```json
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "expires_in": 3600,
  "token_type": "Bearer",
  "scope": "listings:read user:read"
}
```

!!! tip "Building a client with AI"
    If you are scaffolding the client with an AI coding tool, give it both the
    schema-level and narrative context so it generates correct requests:

    - **OpenAPI specification** — [`/api/v1/schema/`](/api/v1/schema/) for
      precise endpoint contracts, field types, and request/response structures.
    - **Full documentation (Markdown)** — [`/full-documentation.md`](/full-documentation.md)
      for authentication, behavior details, and usage examples in one file.

### Optional: Narrow A Token To One User

Partner apps can request a user-scoped token by adding `user_id` to the token
request. This is useful when your system manages multiple Beyond users and you
want a token that can only act on one of them.

```bash
curl -X POST $BASE_URL/o/token/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=listings:read user:read" \
  -d "user_id=42"
```

The response includes `user_id` when the token is narrowed successfully.

#### Token Tiers And `require_user_scoped_tokens`

Beyond classifies scopes into tiers. These tiers decide whether a scope can be
used on an application-level token, a user-scoped token, or both.


| Tier       | Scopes                                                                                                                  | How to request                                                               | Behavior                                                                                                                                                                   |
| ---------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| App-level  | `user:write`, `webhooks:write`                                                                                          | Omit `user_id`.                                                              | Operates on the application as a whole. Rejected if you include `user_id`.                                                                                                 |
| User-level | `listings:read`, `listings:write`, `reservations:read`, `accounts:read`, `insights:read`, `compsets:read`, `neyoba:ask` | Include `user_id` when user-scoped tokens are required for your application. | Acts only on the bound user's resources. If `require_user_scoped_tokens` is disabled, these scopes may also be used without `user_id` for application-level access.        |
| Cross-tier | `user:read`, `webhooks:read`                                                                                            | Omit or include `user_id`.                                                   | Without `user_id`, reads users owned by the application and every webhook event delivered to it. With `user_id`, reads only the bound user and that user's webhook events. |


When `require_user_scoped_tokens` is enabled for your application, token
requests without `user_id` can still use app-level and cross-tier scopes
(`user:write`, `webhooks:write`, `user:read`, `webhooks:read`), but not
user-level scopes. Requests with `user_id` can use user-level and cross-tier
scopes, but never app-level scopes.

### Optional: Narrow A User-Scoped Token To One Credential

If you want the token to behave exactly like one login credential within the
user, add `credential_id`. When omitted, Beyond uses the user's primary
credential by default.

```bash
curl -X POST $BASE_URL/o/token/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=listings:read user:read" \
  -d "user_id=42" \
  -d "credential_id=314"
```

When `credential_id` is present, listing visibility and write access are
constrained by that credential's global permissions and grants.

#### Credential-scoped visibility rules

Once a token is bound to a credential, every endpoint that supports user-scoped
tokens applies the rules below. The rules are identical for listings and managed
accounts: managed accounts are narrowed only when at least one of their listings
would also be narrowed.


| `credential_id` present | Credential `global_permissions` | What the token can see and do                                                                                                                                                                        |
| ----------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No (app-level token)    | n/a                             | All resources owned by every user of the application.                                                                                                                                                |
| Yes                     | `ADMIN`                         | All listings and managed accounts owned by the bound user. `GET /users/<id>/credentials/` returns every non-deleted credential. Can read resources and use listing write endpoints.                  |
| Yes                     | `EDIT`                          | All listings and managed accounts owned by the bound user. `GET /users/<id>/credentials/` returns only the acting credential. Can read resources and use listing write endpoints.                    |
| Yes                     | `VIEW`                          | Same visibility as `EDIT`, but listing write endpoints return `403 Forbidden`.                                                                                                                       |
| Yes                     | `NONE`                          | Only listings explicitly granted to the credential; managed accounts are filtered to those that own at least one granted listing. `GET /users/<id>/credentials/` returns only the acting credential. |


Soft-deleting a credential (for example, when a user removes a login) revokes
the credential for API purposes: any token bound to it will start returning
`403 Forbidden` immediately, and new token requests that resolve to a
soft-deleted credential are rejected at `/o/token/`.

### Make Your First API Call

```bash
curl -X GET "$BASE_URL/api/v1/listings/?page[size]=5" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/vnd.api+json"
```

### End-to-End Onboarding Flow

Once your OAuth2 application has been provisioned, follow this flow to take a
partner's user from creation to a listing that Beyond Pricing is actively
pricing. It focuses on the order of operations and the background sync
lifecycle, and links to the endpoint pages that document request details.

1. Get an application-level token
2. Create a user
3. Add an account for that user
4. Get a user-scoped token for that user
5. Wait for the background sync to finish
6. List the user's listings and map Beyond Pricing listing IDs to your listing IDs
7. Enable the listing so the integration framework will begin pushing prices to the account

```mermaid
sequenceDiagram
    participant Partner as PartnerSystem
    participant BP as BeyondPricing

    Partner->>BP: Get app-level token
    Partner->>BP: Create user
    BP-->>Partner: Return user_id
    Partner->>BP: Add account for user
    BP-->>Partner: Queue sync
    Partner->>BP: Get user-scoped token for user
    loop Until sync completes
        Partner->>BP: List accounts for user
        BP-->>Partner: sync-status = queued/in_progress/completed
    end
    Partner->>BP: List user's listings with user-scoped token
    BP-->>Partner: listing id + channel-listings[].channel-id
    Partner->>BP: Update listing activation
    BP-->>Partner: Price syncing enabled
```

#### Step 1: Get an App-Level Token

Start with an application-level token so you can create the user and add the
user's account. See [Get An Access Token](#get-an-access-token) above for the
client credentials request.

!!! note
    In this onboarding flow, user and account creation use an app-level token
    with `user:write`. After the account is created, request a user-scoped
    token for that user so you can poll account status with `user:read` and
    retrieve listings with `listings:read`.

#### Step 2: Create a User

Create the partner's user first. The user is the container for the account and
for the listings that will later appear in Beyond Pricing.

Use the [Create User](../endpoints/users.md#create-user) endpoint details for
the request and response schema.

#### Step 3: Add an Account for the User

After the user exists, add the partner's channel or PMS account for that user.
This is the step that starts the import process for listings and reservation
history.

Use the [Add Account](../endpoints/managed-accounts.md#add-account) endpoint
details for channel credentials, supported integrations, and response behavior.

#### Step 4: Get a User-Scoped Token

After the account exists, request a
[user-scoped token](#optional-narrow-a-token-to-one-user) for that user. Use
this token for user-specific reads in the remaining steps of the onboarding
flow.

For collection endpoints such as
[List Listings](../endpoints/listings.md#list-listings), a user-scoped token
automatically limits the response to the bound user, so you do not usually need
to add `filter[owner]={user_id}`.

#### Step 5: Wait for the Background Sync

After the account is created, Beyond Pricing starts a background sync that
fetches reservation history for the listings associated with that account. This
can take some time, especially for accounts with many listings or a large
history.

Check the account's `sync-status` by polling `/api/v1/users/{user_id}/accounts`
with the [List Accounts](../endpoints/managed-accounts.md#list-accounts)
endpoint until the state reaches `completed`.

```json
{
  "sync-status": {
    "state": "completed",
    "last-successful-sync-at": "2026-03-06T11:00:00+00:00"
  }
}
```

!!! warning
    Do not try to map listings or enable price syncing before the account sync
    has finished. Until sync is complete, the listing set may still be
    incomplete.

#### Step 6: Map Beyond Pricing Listing IDs to Your Listing IDs

Once the sync has completed, retrieve listings with the
[List Listings](../endpoints/listings.md#list-listings) endpoint using the
user-scoped token for that user. The response is already limited to that user's
listings.

The Beyond Pricing listing ID is the top-level resource `id`. Your listing ID
from the connected channel or PMS is available in
`channel-listings[].channel-id`.

```json
{
  "id": "12345",
  "channel-listings": [
    {
      "channel": "hostaway",
      "channel-id": "external-listing-987"
    }
  ]
}
```

See the [Listings](../endpoints/listings.md) endpoint reference for filtering
behavior, and the [JSON:API guide](../guides/jsonapi.md#filtering) for
collection query conventions.

#### Step 7: Enable a Listing

Listings are created disabled on the Beyond Pricing side. After you have mapped
the listing IDs, enable the listing so Beyond Pricing can start pushing prices
to the connected account.

Use the
[Listing Activation](../endpoints/listings.md#listing-activation) endpoint
reference for the activation payload and response shape.

!!! note
    After a listing is activated, change its nightly base price through the
    [Base Price customization](../endpoints/listing-customizations.md#base-price)
    endpoint. The base price is the anchor used by Beyond Pricing's algorithm,
    so keeping it accurate ensures recommended prices stay aligned with the
    partner's expectations.

#### After Activation

After a listing has been enabled, Beyond Pricing begins the normal
price-syncing lifecycle for that listing.

If you need to inspect the listing again after activation, use the
[Get Listing Details](../endpoints/listings.md#get-listing-details) endpoint.
For API-wide error semantics, see [Error Handling](../guides/error-handling.md).

### Security Notes

- Request only the scopes your integration needs.
- If Beyond has configured an IP allowlist for your application, the same IP
  checks apply to both `/o/*` and `/api/v1/*`.
- `user:write` is app-level only and cannot be combined with `user_id`.
- When `require_user_scoped_tokens` is enabled, request a user-scoped token
  with `user_id` for `listings:read`, `listings:write`, `reservations:read`,
  `accounts:read`, and `insights:read`.
- `user:read` is cross-tier: application-level tokens read all users owned by
  the app; user-scoped tokens read only the bound user.
- User-scoped tokens inherit the selected credential's permissions. A
  credential with limited listing grants will see and modify only the listings
  it is allowed to access.

### Common Pitfalls

- **Using the wrong token type.** User and account creation require an
  app-level token, while listing retrieval is expected to use a user-scoped
  token for the target user. See
  [Narrow A Token To One User](#optional-narrow-a-token-to-one-user).
- **Starting listing mapping too early.** Wait until account `sync-status` is
  `completed`.
- **Confusing Beyond Pricing listing IDs with external channel or PMS listing
  IDs.** Use the listing resource `id` for Beyond Pricing and
  `channel-listings[].channel-id` for your external identifier.
- **Forgetting that field names are dasherized in API payloads.** See
  [JSON:API Format](../guides/jsonapi.md#key-conventions).

### Next Steps

- Review [rate limiting](../guides/rate-limiting.md) before scheduling frequent
  jobs.
- Browse the [endpoint reference](../endpoints/index.md).

---

---
description: >-
  Automate your own Beyond account with the Beyond API using a personal access token — scripts, cron jobs, CLIs, and AI agents.
---

## For Personal Users

You are a **personal user** if you already have a Beyond account with your own
listings and you want to automate tasks on it — scripts, cron jobs, CLIs, or
agents acting on your own listings. You do not run a multi-tenant backend and
you are not managing other people's Beyond accounts. If you are a company
integrating many Beyond users on a secure backend, use the
[For Partners](partners.md) path instead.

Your Beyond account and listings already exist, so — unlike partners — you do
not create users or add channel accounts through the API. This page is the
end-to-end path: how authentication works, how to set it up from the dashboard,
your first authenticated call, and what you can automate afterward.

!!! tip "Migrating from the legacy API?"
    If you previously used the legacy API at `api.beyondpricing.com/api`
    with a `Token` header, see
    [Migrating from the legacy API](../guides/migration-from-legacy-api.md)
    for a side-by-side endpoint mapping.

### When To Use This Path

Choose this profile if your client:

- Represents one Beyond user automating their own account.
- Runs non-interactively (scripts, cron jobs, agents) and can store a single
  secret securely.
- Does not need to act on behalf of other Beyond users.


### Authentication Method

Personal users authenticate with a **personal access token (PAT)**: a
long-lived secret you create yourself from the
[Beyond Settings](https://v2.beyondpricing.com/dashboard/user/personal-access-tokens),
bound to your single Beyond credential. There is no client secret to manage and
no browser step at runtime — you create the PAT once in the dashboard, then
send it on every API request.

The PAT inherits the permissions of the credential it is bound to. What the
token can read and write is controlled by that credential's configuration on
the [Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials)
— the same view and per-listing access you have in the dashboard. Changes to
those permissions take effect immediately for the token.

See [Authentication](authentication.md#personal-access-token-pat) for the full
PAT rules and credential scoping reference.

### End-to-End Flow

1. Sign in to the Beyond dashboard
2. Create a personal access token (PAT) from your dashboard user settings
3. Make your first authenticated API call and confirm it returns your listings
4. Read and write data for your listings (pricing, calendar, customizations,
   recommendations)
5. Optionally, set up a webhook so Beyond pushes changes to you

```mermaid
sequenceDiagram
    participant User as PersonalUser
    participant Dashboard as BeyondDashboard
    participant BP as BeyondPricing

    User->>Dashboard: Sign in
    User->>Dashboard: Create PAT
    Dashboard-->>User: Show bpat_ secret once
    User->>BP: GET /api/v1/listings/ with Authorization Bearer bpat_…
    BP-->>User: Your listings
    User->>BP: Read/write pricing, calendar, customizations
    BP-->>User: Updated data
```

### Step 1: Sign In to the Beyond Dashboard

Sign in at [https://v2.beyondpricing.com](https://v2.beyondpricing.com) with
your Beyond credentials.

### Step 2: Create a Personal Access Token

Create a PAT from your dashboard user settings (Personal Access Tokens). Beyond shows the
cleartext secret **exactly once** at creation time — copy and store it
immediately, like any other high-value credential.

The PAT does not have its own permission settings — it acts with the
permissions of the credential it is bound to. To control which listings the
token can read or modify, manage that credential on the
[Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials).
Permission changes there apply to the token immediately.

### Step 3: Make Your First API Call

Send the PAT in the `Authorization` header. A PAT is already bound to your
user, so the response is limited to your own listings — you do not need a
`filter[owner]` parameter.

```bash
curl -X GET "$BASE_URL/api/v1/listings/?page[size]=5" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/vnd.api+json"
```

If the response lists your properties, authentication is working.

### Step 4: Automate Your Tasks

Call the endpoints your automation needs:

- [Listings](../endpoints/listings.md) — read listing details
- [Calendar](../endpoints/calendar.md) — read availability and pricing calendars
- [Listing Customizations](../endpoints/listing-customizations.md) — adjust base
  price, min price, and other settings
- [Recommendations](../endpoints/recommendations.md) — read pricing
  recommendations

For API-wide error semantics, see [Error Handling](../guides/error-handling.md),
and review [Rate Limiting](../guides/rate-limiting.md) before scheduling
frequent jobs.

### Step 5: Receive Webhooks (optional)

Instead of polling, let Beyond push events to your backend: a new listing, a
base price change, a finished account sync. Webhooks are configured on the same
dashboard page as your personal access tokens, by an administrator of the
account:

1. Generate a signing secret and add it to your receiver.
2. Save a public `https` endpoint URL. Beyond verifies it with a signed
   [`webhook.ping`](../webhooks/webhook-ping.md) before saving.
3. Pick the events you want, or keep the default of every event.

Every delivery is a signed JSON:API document; see [Webhooks](../webhooks/index.md)
for the verification recipe, retries, and the event catalog. Your PAT carries the
`webhooks:read` scope, so you can read back deliveries through the
[Webhook Events](../endpoints/webhook-events.md) endpoints.

### Rotate or Revoke

Revoke a PAT from the same dashboard settings at any time; a revoked token
immediately stops working. The secret cannot be recovered after creation — if
you lose it, revoke it and create a new one.

Beyond Pro also controls your tokens. A PAT needs an active Beyond Pro trial or
subscription. When your trial or subscription ends, Beyond revokes your tokens
and every request answers `401`. Renew Beyond Pro, then create a new token.

### Example Use Cases

These are common automations a personal user can build with
basic-to-intermediate development skills. Each one only needs your PAT and a
scheduled script (for example, a daily cron job).

!!! tip "Building a client with AI"
    If you are scaffolding the client with an AI coding tool, give it both the
    schema-level and narrative context so it generates correct requests:

    - **OpenAPI specification** — [`/api/v1/schema/`](/api/v1/schema/) for
      precise endpoint contracts, field types, and request/response structures.
    - **Full documentation (Markdown)** — [`/full-documentation.md`](/full-documentation.md)
      for authentication, behavior details, and usage examples in one file.

    This is the fastest way to turn the use cases below into working code.

#### 1. Pricing recommendation digest

Get notified about Beyond's latest base-price suggestions and, optionally,
apply the ones you trust automatically — instead of logging into the dashboard
to check.

How to build it: list your listings with
[Listings](../endpoints/listings.md), then for each listing read pending
suggestions from [Recommendations](../endpoints/recommendations.md). Summarize
them into an email or chat message. To act on a suggestion, push the value with
the `base-price` [Listing Customization](../endpoints/listing-customizations.md).
Run it on a daily schedule.

#### 2. Occupancy and revenue report

Export a forward-looking view of bookings and nightly prices across all your
listings into a spreadsheet or BI dashboard, so you can track occupancy and
projected revenue without manual data entry.

How to build it: enumerate your listings with
[Listings](../endpoints/listings.md), then for each one pull the daily
[Calendar](../endpoints/calendar.md) for your reporting window (using
`filter[start-date]` and `filter[end-date]`). The calendar returns availability
status and price per night — aggregate those into occupancy and revenue figures
and write them to a CSV or sheet on a daily or weekly cadence.

This is one request per listing, so pace it: past a few hundred listings an
unthrottled loop will hit a per-minute [rate limit](../guides/rate-limiting.md)
partway through and leave your report silently incomplete. Spread the sweep out,
and retry any `429` rather than skipping the listing. A year of calendar comes
back in a single response, so there is no need to page each listing's dates.

#### 3. Seasonal rules automation

Apply consistent pricing and stay rules across your whole portfolio ahead of a
high or low season — for example, raising price floors and requiring longer
minimum stays for summer — without editing each listing by hand.

How to build it: list your listings with
[Listings](../endpoints/listings.md), then apply the rules per listing through
[Listing Customizations](../endpoints/listing-customizations.md): use
`min-max-prices` for seasonal price floors and ceilings and `min-stays` for
seasonal minimum-night rules. Run the script once when a season is approaching,
or keep the desired rules in a config file and reconcile on a schedule.

### Security Notes

- The PAT secret is shown only once at creation. Store it like any other
  high-value credential.
- The token acts with its credential's permissions. Keep that credential's
  access on the [Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials)
  scoped to what your automation needs.
- Revoke immediately if a token is exposed, then create a replacement.

### Common Pitfalls

- **Losing the PAT secret.** Beyond shows it only once at creation. If you lose
  it, revoke it and create a new one — it cannot be retrieved later.
- **Expecting more access than the credential allows.** The token cannot do
  more than its credential permits. If a call is denied, check that credential's
  permissions on the
  [Settings / Team page](https://v2.beyondpricing.com/dashboard/user/credentials).
- **Forgetting that field names are dasherized in API payloads.** See
  [JSON:API Format](../guides/jsonapi.md#key-conventions).

### Next Steps

- Review [error handling](../guides/error-handling.md) and
  [rate limiting](../guides/rate-limiting.md).
- Browse the [endpoint reference](../endpoints/index.md).

---

---
description: >-
  Connect an AI agent such as Claude or ChatGPT to the Beyond MCP server: OAuth2 authorization, consent, and available tools.
---

## Connecting to the Beyond MCP Server

This guide explains how an MCP client (an AI agent such as Claude or ChatGPT)
authenticates a user and establishes an authorized connection to the Beyond MCP
server.

### TL;DR

The MCP server uses standard OAuth 2.0. Your client discovers the authorization
server from the MCP server's metadata, registers itself, runs the Authorization
Code flow with PKCE so the user can log in and grant access, then calls the MCP
tools with the resulting bearer token. Most MCP clients automate all of this
once you point them at the server URL.

### What you connect to

| Item | Value |
|------|-------|
| MCP server URL | `https://neyoba.beyondpricing.com/mcp` |
| Authorization server | `https://developers.beyondpricing.com` |
| Sign-in / consent page | `https://v2.beyondpricing.com/oauth/authorize` |
| Required scope | `neyoba:ask` |

Client requirements:

- Public client: token endpoint authentication method `none` (no client secret).
- PKCE is required, using `S256`.
- Supported grants: `authorization_code`, `refresh_token`.

### Endpoint reference

| Purpose | Method | URL |
|---------|--------|-----|
| Protected-resource metadata (RFC 9728) | GET | `https://neyoba.beyondpricing.com/.well-known/oauth-protected-resource/mcp` |
| Authorization-server metadata (RFC 8414) | GET | `https://developers.beyondpricing.com/.well-known/oauth-authorization-server` (see note) |
| Dynamic client registration (RFC 7591) | POST | `https://developers.beyondpricing.com/o/register/` |
| Authorization (sign-in / consent) | GET (browser) | `https://v2.beyondpricing.com/oauth/authorize` |
| Token / refresh | POST | `https://developers.beyondpricing.com/o/token/` |
| Revocation | POST | `https://developers.beyondpricing.com/o/revoke_token/` |

You normally do not hardcode these. Discover them from the metadata documents in
steps 1–2; the values above are shown so you know what to expect.

#### A note on discovering the authorization server (two paths)

There are two ways clients discover the authorization-server metadata, and they
return two slightly different (but equivalent) documents:

- **Spec-compliant clients (e.g. Claude)** read the protected-resource metadata
  (step 1), follow its `authorization_servers` pointer to
  `https://developers.beyondpricing.com`, and fetch the authorization server's
  own metadata from
  `https://developers.beyondpricing.com/.well-known/oauth-authorization-server`.
  This is the path drawn in the diagram below.
- **Clients that do not follow the `authorization_servers` pointer (e.g.
  ChatGPT)** instead look for authorization-server metadata at the MCP server's
  own origin. For compatibility, the MCP server replicates that document at
  `https://neyoba.beyondpricing.com/mcp/.well-known/oauth-authorization-server`
  (and `.../mcp/.well-known/openid-configuration`, plus the origin-root
  variants). Step 2 below shows this replica.

Both documents advertise the same authorization, token, registration, and
revocation endpoints, so either discovery path leads to the same flow. They
differ only cosmetically: the replica's `issuer` is the MCP resource URL and it
lists just the `neyoba:ask` scope, while the authorization server's own document
uses its own host as `issuer` and may list additional scopes and grant types.

### Flow

```mermaid
sequenceDiagram
    participant U as User
    participant C as MCP Client
    participant M as MCP Server
    participant A as Authorization Server
    participant V as Consent Page

    C->>M: POST /mcp without bearer token
    M-->>C: 401 WWW-Authenticate with resource_metadata
    C->>M: GET /.well-known/oauth-protected-resource/mcp
    M-->>C: authorization_servers and resource
    C->>A: GET /.well-known/oauth-authorization-server
    A-->>C: authorize, token, register, revoke endpoints
    C->>A: POST /o/register/
    A-->>C: client_id
    C->>V: GET /oauth/authorize with PKCE and resource via user browser
    U->>V: Log in and grant neyoba:ask
    V-->>C: Redirect to redirect_uri with authorization code
    C->>A: POST /o/token/ with code and code_verifier
    A-->>C: access_token and refresh_token
    C->>M: POST /mcp with Bearer access_token
    M-->>C: Tool result with response and thread_id
```

> The diagram shows the spec-compliant discovery path: the client fetches
> authorization-server metadata from the authorization server (`A`) after
> following the `authorization_servers` pointer. Clients that read the replica
> at the MCP origin instead (see the note above) fetch it from `M` in step 2.

#### 1. Discover the authorization server

Call the MCP endpoint with no credentials; the `401` response carries a
`WWW-Authenticate` header whose `resource_metadata` parameter points at the exact
protected-resource metadata URL. For this server that URL is
`https://neyoba.beyondpricing.com/.well-known/oauth-protected-resource/mcp` (the
resource path `mcp` is appended after the well-known name, per RFC 9728):

```http
GET /.well-known/oauth-protected-resource/mcp HTTP/1.1
Host: neyoba.beyondpricing.com
```

The response advertises `authorization_servers: ["https://developers.beyondpricing.com/"]`
and `resource: "https://neyoba.beyondpricing.com/mcp"`.

#### 2. Fetch authorization-server metadata

Spec-compliant clients fetch this from the authorization server they just
discovered (`https://developers.beyondpricing.com/.well-known/oauth-authorization-server`).
Clients that do not follow the `authorization_servers` pointer (e.g. ChatGPT)
fetch the replica served at the MCP origin instead — shown here:

```http
GET /mcp/.well-known/oauth-authorization-server HTTP/1.1
Host: neyoba.beyondpricing.com
```

```json
{
  "issuer": "https://neyoba.beyondpricing.com/mcp",
  "authorization_endpoint": "https://v2.beyondpricing.com/oauth/authorize",
  "token_endpoint": "https://developers.beyondpricing.com/o/token/",
  "registration_endpoint": "https://developers.beyondpricing.com/o/register/",
  "revocation_endpoint": "https://developers.beyondpricing.com/o/revoke_token/",
  "scopes_supported": ["neyoba:ask"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}
```

(The authorization server's own document at `developers.beyondpricing.com` is
equivalent for this flow but uses its own host as `issuer` and may list
additional scopes, grant types, and token-endpoint auth methods.)

#### 3. Register the client (Dynamic Client Registration, RFC 7591)

```http
POST /o/register/ HTTP/1.1
Host: developers.beyondpricing.com
Content-Type: application/json

{
  "client_name": "Example Agent",
  "redirect_uris": ["https://client.example.com/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

Returns a `client_id`. The client is public (`token_endpoint_auth_method: none`),
so there is no `client_secret`; security comes from PKCE.

#### 4. Authorize (Authorization Code + PKCE)

Generate a PKCE `code_verifier` and its `S256` `code_challenge`, then send the
user's browser to the sign-in / consent page:

```
https://v2.beyondpricing.com/oauth/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=https://client.example.com/oauth/callback
  &scope=neyoba:ask
  &state=<opaque-state>
  &code_challenge=<base64url-sha256>
  &code_challenge_method=S256
  &resource=https://neyoba.beyondpricing.com/mcp
```

The user logs in and grants the `neyoba:ask` scope. The browser is redirected to
`redirect_uri` with `?code=<authorization_code>&state=<state>`. Verify `state`
matches what you sent.

#### 5. Exchange the code for tokens

```http
POST /o/token/ HTTP/1.1
Host: developers.beyondpricing.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=https://client.example.com/oauth/callback
&client_id=<client_id>
&code_verifier=<pkce_verifier>
&resource=https://neyoba.beyondpricing.com/mcp
```

Returns an `access_token`, a `refresh_token`, `token_type: Bearer`, `expires_in`,
and `scope`. Treat the access token as opaque; do not attempt to decode it.

#### 6. Call the MCP server

Send the bearer token with every MCP request:

```http
POST /mcp HTTP/1.1
Host: neyoba.beyondpricing.com
Authorization: Bearer <access_token>
Content-Type: application/json
```

If a request returns `401` or `403`, the token is expired or no longer valid:
refresh it (step 7) or run the authorization flow again.

#### 7. Refresh the access token

```http
POST /o/token/ HTTP/1.1
Host: developers.beyondpricing.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
```

#### 8. Revoke (disconnect)

```http
POST /o/revoke_token/ HTTP/1.1
Host: developers.beyondpricing.com
Content-Type: application/x-www-form-urlencoded

token=<access_or_refresh_token>
&client_id=<client_id>
```

### Notes

- The MCP server never sees the user's login credentials. Sign-in and consent
  happen on the Beyond authorization pages.
- Access tokens are opaque. Do not parse or rely on their contents.
- Include `resource=https://neyoba.beyondpricing.com/mcp` on the authorization
  and token requests so the issued token is scoped to this MCP server.

---

---
description: >-
  Guides for building a robust Beyond API integration: JSON:API format, rate limiting, error handling, versioning, and legacy migration.
---

## Guides

These guides cover the key concepts and patterns you need to build a robust integration with the Beyond API.

| Guide | Description |
|-------|-------------|
| [For Partners](../getting-started/partners.md) | End-to-end flow for partners: token setup and scoping, creating a user, adding an account, waiting for sync, mapping listing IDs, and enabling price syncing |
| [For Personal Users](../getting-started/personal-users.md) | End-to-end flow for an individual Beyond user automating their own listings: dashboard auth setup, first call, and example automations |
| [Migrating from the legacy API](migration-from-legacy-api.md) | Map legacy `api.beyondpricing.com/api` endpoints to v1 and flag features not yet available |
| [JSON:API Format](jsonapi.md) | Request/response structure, field naming, content types, pagination, sorting, and filtering |
| [Rate Limiting](rate-limiting.md) | Understand rate limits and implement retry logic |
| [Error Handling](error-handling.md) | Error response format and common error codes |
| [Versioning](versioning.md) | API versioning policy and backward compatibility |

---

## Migrating from the legacy API

The legacy Beyond Pricing API at `https://api.beyondpricing.com/api`
(documented at `https://api-docs.beyondpricing.com`) is being replaced by this
**Developers API v1**. This guide maps each legacy endpoint to its v1 equivalent
and flags features that are not yet available.

If you are starting from scratch, follow
[For Personal Users](../getting-started/personal-users.md) — it is the full
end-to-end setup walkthrough.

### Why the new API

- **Stable, versioned contract** — URL-based versioning with a clear
  deprecation policy. See [Versioning](versioning.md).
- **Standard request, response, and error format** — JSON:API v1.1 with
  consistent envelopes and typed errors. See
  [JSON:API Format](jsonapi.md) and [Error Handling](error-handling.md).
- **Built-in pagination** on every list endpoint. See
  [JSON:API Format](jsonapi.md).
- **Transparent rate limiting** via `X-RateLimit-*` response headers and
  `Retry-After` on 429s. See [Rate Limiting](rate-limiting.md).
- **Safer authentication** — PATs are hashed at rest (the cleartext secret
  exists only on your machine), are scoped to a single Beyond credential
  instead of the whole user, and can be given an explicit expiry date.
- **Token per credential, not per user** — finer-grained permission control.
  A PAT inherits its credential's listing access and can be revoked without
  affecting your other automations. See
  [Authentication → Personal Access Token](../getting-started/authentication.md#personal-access-token-pat).
- **Live Swagger UI** for browsing and testing endpoints from the browser:
  [`/api/v1/docs/`](/api/v1/docs/).
- **First-class AI-assisted client development** — point your tooling at the
  [OpenAPI schema](/api/v1/schema/) and the
  [full markdown documentation](/full-documentation.md) in one place.

### Authentication

The legacy API used a single `Token` header per Beyond user, created from User
Settings. The v1 equivalent is a **personal access token (PAT)**, a Bearer
token prefixed with `bpat_` that you create from the Beyond dashboard and bind
to a single credential.

- Create your PAT — see
  [For Personal Users → Step 2](../getting-started/personal-users.md#step-2-create-a-personal-access-token).
- Send it as `Authorization: Bearer <token>` on every request.
- For full PAT rules and credential scoping, see
  [Authentication → Personal Access Token](../getting-started/authentication.md#personal-access-token-pat).

### Base URL and response format

| | Legacy | v1 |
|---|---|---|
| Base URL | `https://api.beyondpricing.com/api` | `https://developers.beyondpricing.com/api/v1/` |
| Format | Ad-hoc JSON | [JSON:API v1.1](jsonapi.md) (`application/vnd.api+json`) |
| Field names | `snake_case` (e.g. `base_price`) | `dasherized` (e.g. `base-price`) |
| Pagination | None | [Page-based](jsonapi.md) on every list endpoint |

### Endpoint mapping

| Legacy | v1 equivalent | Notes |
|---|---|---|
| `GET /listings/{id}` | [`GET /api/v1/listings/{id}/`](../endpoints/listings.md), plus [Calendar](../endpoints/calendar.md) and [Listing Customizations](../endpoints/listing-customizations.md) | Legacy bundled the listing record, embedded calendar, and pricing settings in one response. v1 splits them into composable endpoints. |
| `POST /listings/{id}` | [Listing Customizations](../endpoints/listing-customizations.md) (`base-price`, `min-stays`, `min-max-prices`, …) | Mutations now go through per-setting customization endpoints. |
| `POST /listings/{id}/calendar` (per-date `price_user` / `min_stay_user`) | [Listing Customizations](../endpoints/listing-customizations.md) (`base-price`, `min-stays`, `time-based-adjustments`) | The single per-date write was broken into typed customization endpoints. |
| `GET /accounts` | [`GET /api/v1/users/{user_id}/accounts/`](../endpoints/managed-accounts.md) | Accounts are now scoped per user. |
| `POST /insights/market-stats` | [`GET /api/v1/listings/{id}/market-insights/`](../endpoints/insights.md) | **Deliberately different data.** Legacy returned aggregates over *connected* properties in a market, and took a market as input. v1 is anchored to one of your listings and compares it, day by day, against the scraped benchmark for its own neighborhood — the comparison the Beyond app charts. See [What changed](#market-insights-what-changed). |
| `POST /insights/account-stats` | [`GET /api/v1/listings/`](../endpoints/listings.md) and [`GET /api/v1/users/{user_id}/accounts/`](../endpoints/managed-accounts.md) | No single equivalent. Enumerate accounts and listings, then read per-listing performance from the relevant endpoints. |
| `POST /insights/listings_performance_revpan_user` | [Compsets](../endpoints/compsets.md) | Per-listing performance is now exposed through the compset `performance` block. |

### Market insights: what changed

The legacy `POST /insights/market-stats` answered "how is this **market**
doing?" from connected Beyond properties. The v1
[Market Insights](../endpoints/insights.md) endpoint answers "how is **my
listing** doing against its neighborhood?" — a different, more directly usable
question. Three practical consequences when porting:

- **The input is a listing, not a market.** You no longer name a market; the
  comparison cohort is derived from the listing itself. This also means you
  cannot query a market you have no listing in.
- **The benchmark is scraped, not connected.** It is built from public OTA
  listing data for comparable properties near your listing, rather than from
  other Beyond customers' connected reservations — a broader and more
  representative sample of the neighborhood.
- **Both sides come back together.** Each entry carries your listing's metric
  and the benchmark's for the same stay date, so no second call and no joining
  on your side.

Read the [`null` semantics](../endpoints/insights.md#null-never-means-zero)
before charting the response: `0.0` is a real measurement and `null` means
"not computable", and conflating them will distort your averages.

### What's new in v1

Capabilities that did not exist in the legacy API:

- [Recommendations](../endpoints/recommendations.md) — read pricing
  suggestions per listing.
- Per-listing [activation](../endpoints/listings.md) and listing/account
  refresh endpoints.

### Next steps

- Walk through [For Personal Users](../getting-started/personal-users.md) to
  get a PAT and make your first call.

---

## JSON:API Format

The Beyond API follows the [JSON:API specification](https://jsonapi.org/) (v1.1) for all request and response formatting.

### Key Conventions

- **Resource objects** include `type`, `id`, and `attributes`
- **Field names** are dasherized (e.g., `base-price` instead of `base_price`)
- **Query parameter names** are dasherized too (e.g., `filter[start-date]`, `include-body`, `recent-sync-threshold-minutes`). Where an older snake_case spelling is still accepted, the endpoint's page says so; use the dasherized name in new integrations.
- **Resource types** (`type`) are **plural and dasherized** (e.g., `listings`, `users`, `calendar-entries`). Compound resource types use a singular modifier on a plural head noun — `min-stay-customizations`, not `min-stays-customizations` — even when the endpoint path segment is plural (`/customizations/min-stays/`). That path-vs-type difference is intentional; treat `type` as the stable identifier to match on.

### Content-Type

Requests should use the JSON:API media type:

```
Content-Type: application/vnd.api+json
```

The API also accepts `application/json` for backward compatibility.

### Response Format

#### Single Resource

```json
{
  "data": {
    "type": "listings",
    "id": "12345",
    "attributes": { ... }
  }
}
```

#### Collection

Collections include `meta` with pagination info and `links` for navigation (see [Pagination](#pagination)):

```json
{
  "data": [ ... ],
  "meta": { "pagination": { "count": 150, "page": 1, "pages": 6 } },
  "links": { "first": "...", "last": "...", "next": "...", "prev": null }
}
```

#### Error Response

```json
{
  "errors": [
    {
      "status": "404",
      "detail": "Listing 12345 not found or not owned by this application",
      "source": {"pointer": "/data"},
      "code": "not_found"
    }
  ]
}
```

`code` is the stable identifier to branch on. Note that `title` is **not**
present on most errors. See [Error Handling](error-handling.md) for the full
error-object structure, the status codes the API returns, and best practices.

### Request Format (POST/PATCH)

When creating or updating resources, wrap the payload in a `data` object:

```json
{
  "data": {
    "type": "users",
    "attributes": {
      "first-name": "John",
      "last-name": "Doe",
      "email": "john@example.com"
    }
  }
}
```

### Compound Documents (Sideloading)

Some endpoints support including related resources in a single request using the `?include=` parameter. This eliminates the need for multiple API calls.

```
GET /api/v1/listings/12345/?include=owner
```

Related resources appear in the top-level `included` array, with the relationship declared in `relationships`:

```json
{
  "data": {
    "type": "listings",
    "id": "12345",
    "attributes": { ... },
    "relationships": {
      "owner": { "data": {"type": "users", "id": "789"} }
    }
  },
  "included": [
    {
      "type": "users",
      "id": "789",
      "attributes": { ... }
    }
  ]
}
```

See each endpoint's documentation in the [Swagger UI](/api/v1/docs/) for the list of supported `include` values.

### Pagination

Collection endpoints return paginated results. Use query parameters to control pagination, sorting, and filtering.

All collection endpoints use page-based pagination:

| Parameter | Description |
|-----------|-------------|
| `page[number]` | Page number (1-indexed). Defaults to `1` |
| `page[size]` | Items per page. The default and maximum depend on the endpoint |

**Page sizes are not the same on every endpoint.** Most collections default to
**25** items per page with a maximum of **100**, but the date-series endpoints
return far larger pages so a normal date range fits in one response:

| Endpoint | Default `page[size]` | Maximum |
|----------|----------------------|---------|
| [Listings](../endpoints/listings.md), [Compsets](../endpoints/compsets.md), [Users](../endpoints/users.md), accounts, webhook events | 25 | 100 |
| [Calendar](../endpoints/calendar.md) | 366 | 731 |
| [Market insights](../endpoints/insights.md) | 366 | 731 |

The calendar default is deliberate: a full year of daily entries arrives in a
single response, so a default 365-day window needs **one** request rather than
15. Do not paginate a date range into 25-entry pages — it multiplies your
request count for no benefit and will run you into
[rate limits](rate-limiting.md).

!!! warning "Only `page[number]` and `page[size]` are recognized"
    Any other `page[…]` key — `page[limit]` and `page[offset]` among them — is
    **silently ignored**. The request succeeds with `200` and the default page
    size, so a client using the wrong parameter name gets working-looking
    responses while paging through far more requests than it intended. If a
    `page[size]` you set does not seem to apply, check the parameter name first.

Responses include pagination info in `meta` and navigation links in `links`:

```json
{
  "meta": {
    "pagination": {
      "count": 150,
      "page": 1,
      "pages": 6
    }
  },
  "links": {
    "first": "...?page%5Bnumber%5D=1",
    "last": "...?page%5Bnumber%5D=6",
    "next": "...?page%5Bnumber%5D=2",
    "prev": null
  }
}
```

Use the `links` URLs to navigate between pages without constructing URLs manually.

### Sorting

Use the `sort` parameter with field names, dasherized like every other field
name. Prefix with `-` for descending order. Multiple sort fields are
comma-separated.

```bash
# Newest first
?sort=-created-at

# Multiple fields: by city ascending, then newest first
?sort=city,-created-at
```

**Each endpoint sorts on its own small set of fields, and an unsupported field
is an error, not a no-op.** Sorting on a field the endpoint does not support
returns `400` with `"invalid sort parameter: <field>"` — the request does not
fall back to the default order. Listings, for example, sort on `created-at`,
`title`, and `city` only; a `sort=-base-price` fails.

Each endpoint documents its own sortable fields in the [Swagger UI](/api/v1/docs/).

### Filtering

Use `filter[field]` parameters to narrow results.

```bash
# Single filter
?filter[enabled]=true

# Multiple filters
?filter[owner]=123&filter[enabled]=true
```

On the collection endpoints that filter through the query layer — listings,
compsets, and users — an unsupported filter is an error, like `sort`: the API
returns `400` with `"invalid filter[<field>]"`.

!!! warning "Date-range filters are validated differently"
    The endpoints that expose a date window — [calendar](../endpoints/calendar.md),
    [market insights](../endpoints/insights.md),
    [manual overrides](../endpoints/listing-customizations.md#manual-overrides),
    and [webhook events](../endpoints/webhook-events.md) — read those filters
    directly rather than through the query layer, so an unrecognized
    `filter[…]` key there is **silently ignored** instead of rejected. Getting
    the name wrong — `filter[start]` instead of
    `filter[start-date]`, say — returns `200` with the endpoint's *default*
    range rather than an error. Check the parameter names on the endpoint's own
    page, and verify the dates in the response match what you asked for.

Each endpoint documents its own available filters in the [Swagger UI](/api/v1/docs/).

### Putting It All Together

Combine pagination, sorting, and filtering in a single request:

```bash
curl -X GET "$BASE_URL/api/v1/listings/?page[number]=1&page[size]=25&sort=-created-at&filter[enabled]=true" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/vnd.api+json"
```

### Types in Webhook Payloads

A [webhook delivery](../webhooks/index.md) is a JSON:API document too, and it carries two
fields named `type`:

```json
{
  "meta": { "type": "listing.refreshed", "...": "..." },
  "data": { "type": "listing-refreshed-events", "...": "..." }
}
```

**`data.type`** is the ordinary JSON:API resource type — required by the spec, and the key
you deserialize on. JSON:API is deliberately agnostic about inflection (singular vs.
plural, dashes vs. underscores) but demands consistency, so event resource types follow
the same dasherized-plural convention as the rest of the API.

**`meta.type`** is a Beyond extension, and it is the field you should **route on**. JSON:API
reserves the `meta` object for exactly this: implementation-specific information the spec
itself says nothing about — and the spec says nothing at all about events or webhooks.

Why not a top-level `type`, as [Standard Webhooks](https://www.standardwebhooks.com/)
suggests? Because that would be *illegal* JSON:API. The spec restricts a document's
top-level members to `data`, `errors`, `meta`, `jsonapi`, `links`, and `included`; a
top-level `type` is not among them. Nesting the event type under `meta` satisfies both
specifications at once.

#### Where the event id lives

For the same reason, the event's ULID appears in `data.id` rather than in `meta`. JSON:API
requires every resource object to carry an `id`, and the resource this document describes
*is* the event — so `data.id` is the event id, not the id of the listing or account the
event concerns. Standard Webhooks, meanwhile, puts the identifier in the `webhook-id`
header and uses it as the idempotency key. Between them the two specs already account for
the id twice; a third copy under `meta` would be redundant, so there is not one.

---

## Rate Limiting

The API enforces rate limits to protect platform stability. Two limits run on
every request and **both** must pass: a global one and, on some endpoints, a
stricter per-endpoint one.

### What Shares a Bucket

Which requests count against the same counter depends on how you authenticate.

#### Personal access tokens

Requests authenticated with a
[personal access token](../getting-started/personal-users.md) are counted per
Beyond user. That budget is not additive: every PAT you create for the same
Beyond user draws on the *same* counter. Issuing a second token to run a second
job in parallel does not buy more capacity — it splits the capacity you already
had. Pace the work instead.

#### Partner applications

Requests from a
[partner integration](../getting-started/partners.md) are counted per OAuth2
application, across every user you act on behalf of. Adding users to your
integration does not raise the limit, so a partner's effective throughput never
multiplies with their user count — budget for your busiest moment across the
whole tenant base, not per user.

### How It Works

#### Application-Level Rate Limit

Every application has a configurable rate limit, applied across all endpoints:
any request counts toward it regardless of what it calls. For personal access
tokens this bucket is per user, as described above.

#### View-Level Rate Limit

Some endpoints enforce a stricter per-endpoint limit on top of the global one,
so heavy traffic on one endpoint cannot crowd out the rest of the API. This
counter is scoped the same way as the global one — per user for personal access
tokens, per application for partners — but is separate for each endpoint. Both
limits must pass for a request to succeed, and the `X-RateLimit-*` headers
describe whichever of the two is closest to being exhausted.

!!! tip "Which limit did you hit?"
    Compare `X-RateLimit-Limit` on the `429` against your application-level
    limit. A lower value means an endpoint-specific limit stopped you, and
    slowing down calls to *that* endpoint is enough — the rest of your
    integration can keep running at full speed.

### Response Headers

Responses from an endpoint include rate-limit headers reflecting the most constrained active limit:

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Seconds until the window resets |

!!! warning "Not every response carries them"
    Requests turned away before they reach an endpoint — `401` unauthorized and
    `403` insufficient scope among them — come back without any `X-RateLimit-*`
    headers, as do applications with no rate limit configured.

    Check a header is present before reading it. A missing header reads back as
    `null` or an empty string in most clients, and numeric parsing quietly turns
    that into `0` — which is indistinguishable from a genuinely exhausted
    budget, so a client that skips the check can put itself to sleep waiting for
    a window that was never full.

### Rate Limit Exceeded (429)

When the rate limit is exceeded, the API responds with HTTP 429 and a `Retry-After` header:

```json
{
  "errors": [
    {
      "status": "429",
      "detail": "Request was throttled. Expected available in 42 seconds.",
      "source": {"pointer": "/data"},
      "code": "throttled"
    }
  ]
}
```

**Headers on 429 responses:**

```
Retry-After: 42
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 42
```

### Usage Limits (429)

Accounts on usage-based plans also have a monthly usage allocation. When it is exhausted, the API responds with HTTP 429 as well -- but this is a different limit than the rate limits above, and retrying within the rate window will not help.

Responses for usage-metered accounts include usage headers:

| Header | Description |
|--------|-------------|
| `X-Usage-Limit` | Units included in the current month |
| `X-Usage-Used` | Units consumed so far this month |
| `X-Usage-Remaining` | Units remaining this month |
| `X-Usage-Reset` | ISO 8601 timestamp of the next monthly reset |

### Telling The Two 429s Apart

Both limits answer with `429`, so branch on the error's `code`:

| Signal | Rate limit 429 | Usage limit 429 |
|--------|----------------|-----------------|
| Error `code` | `throttled` | `quota_exceeded` or `overage_budget_exhausted` |
| `X-RateLimit-Remaining` | `0` | Usually nonzero (the rate window is not the problem) |
| `X-Usage-Remaining` | -- | `0` |
| `Retry-After` | Seconds until the rate window resets | Seconds until the monthly usage reset |

The `X-RateLimit-*` headers always describe the rate window and the `X-Usage-*` headers always describe the monthly allocation; each family stays accurate regardless of which limit triggered the 429. `Retry-After` is the universal backoff signal -- it is correct in both cases.

If your plan does not include API access at all, the API responds with HTTP 403 and error code `plan_not_included` instead.

### Best Practices

- **Monitor headers**: check `X-RateLimit-Remaining` (and `X-Usage-Remaining` where present) to throttle requests client-side before hitting the limit — and confirm the header is present before parsing it, since not every response carries them
- **Respect `Retry-After`**: always wait the indicated time before retrying after a 429 -- for usage-limit 429s this points at the monthly reset, so do not retry sooner
- **Exponential backoff**: use exponential backoff when retrying after rate-limit errors
- **Request only what you need**: filter results and use the largest page size that suits you, to reduce the number of calls. Page-size defaults differ per endpoint — the [calendar](../endpoints/calendar.md) already returns a full year in one response, so do not page it into small chunks
- **Pace portfolio-wide sweeps**: a job that walks every listing and fetches each one's calendar issues one request per listing. Above a few hundred listings that will exceed a per-minute limit long before the job finishes — spread the sweep over time rather than firing it as fast as the client can loop
- **Retry the 429s, do not drop them**: a throttled request returned no data. Skipping it leaves a silent gap in whatever you were building, so honor `Retry-After` and re-issue it

### Putting It Together

The client below walks every page of the [listings](../endpoints/listings.md)
endpoint without tripping the rate limit — and recovers, instead of losing
data, if a `429` does happen. The same wrapper works unchanged for any other
endpoint. It applies the practices above:

1. **Pace from the headers instead of hard-coding your limit.** Spreading the
   advertised limit over the minute (with 10% spare) keeps working when your
   limit changes, and automatically slows down for the stricter per-endpoint
   limits, because the headers always describe the tightest active limit. Only
   when `X-RateLimit-Remaining` hits zero does it stop and wait out the window.
2. **Check a header is present before reading it** — not every response
   carries them (see the warning above).
3. **Branch on the error `code` before honoring `Retry-After`.** A usage-limit
   `429` puts the *monthly* reset in `Retry-After`, so sleeping on it
   indiscriminately can stall a job for weeks.

**Node**

```javascript
const BASE = "https://developers.beyondpricing.com/api/v1";

// One paced client. The pacing state lives in this closure, so a second
// client for another job never shares it.
function createClient(token) {
  const headers = { Authorization: `Bearer ${token}` };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  let nextSlot = 0;

  // How long to hold off before the next request, read off the response.
  const pauseAfter = (res) => {
    // `has` before `get`: a missing header reads back as null, and
    // Number(null) is 0 — indistinguishable from "no budget left".
    const num = (h) => (res.headers.has(h) ? Number(res.headers.get(h)) : NaN);
    const limit = num("X-RateLimit-Limit");
    const left = num("X-RateLimit-Remaining");
    const reset = num("X-RateLimit-Reset");
    if (!Number.isFinite(limit) || !Number.isFinite(left) || limit <= 0) return 0;
    if (left <= 0) return Math.max(reset, 1) * 1000; // out of budget: wait it out
    return 60_000 / (limit * 0.9); // spread the minute's allowance, 10% spare
  };

  return async function get(path) {
    for (let attempt = 0; attempt < 5; attempt++) {
      const wait = nextSlot - Date.now();
      if (wait > 0) await sleep(wait);

      const res = await fetch(`${BASE}${path}`, { headers });
      nextSlot = Date.now() + pauseAfter(res);

      if (res.ok) return res.json();
      if (res.status !== 429) throw new Error(`HTTP ${res.status} on ${path}`);

      const error = (await res.json()).errors?.[0];
      // Only a rate-limit 429 is worth waiting out: a usage-limit 429
      // puts the monthly reset in Retry-After.
      if (error?.code !== "throttled") throw new Error(`${error.code}: ${error.detail}`);
      await sleep(Number(res.headers.get("Retry-After") ?? 5) * 1000);
    }
    throw new Error(`giving up on ${path}`);
  };
}

const get = createClient(process.env.BEYOND_TOKEN);

const listings = [];
for (let page = 1; ; page++) {
  const body = await get(`/listings/?page[size]=100&page[number]=${page}`);
  listings.push(...body.data);
  if (page >= body.meta.pagination.pages) break;
}
```

**Python**

```python
import os
import time

import requests

BASE = "https://developers.beyondpricing.com/api/v1"


class PacedClient:
    def __init__(self, token: str) -> None:
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {token}"
        self.next_slot = 0.0

    def _pause_after(self, response: requests.Response) -> float:
        """Seconds to hold off before the next request, read off the response."""
        headers = response.headers
        # Not every response carries the rate-limit headers — check first.
        if "X-RateLimit-Limit" not in headers or "X-RateLimit-Remaining" not in headers:
            return 0.0
        limit = int(headers["X-RateLimit-Limit"])
        remaining = int(headers["X-RateLimit-Remaining"])
        reset = int(headers.get("X-RateLimit-Reset", "1"))
        if limit <= 0:
            return 0.0
        if remaining <= 0:
            return float(max(reset, 1))  # out of budget: wait it out
        return 60.0 / (limit * 0.9)  # spread the minute's allowance, 10% spare

    def get(self, path: str) -> dict:
        for _ in range(5):
            wait = self.next_slot - time.monotonic()
            if wait > 0:
                time.sleep(wait)

            response = self.session.get(f"{BASE}{path}")
            self.next_slot = time.monotonic() + self._pause_after(response)

            if response.ok:
                return response.json()
            if response.status_code != 429:
                response.raise_for_status()

            error = response.json()["errors"][0]
            # Only a rate-limit 429 is worth waiting out: a usage-limit 429
            # puts the monthly reset in Retry-After.
            if error["code"] != "throttled":
                raise RuntimeError(f"{error['code']}: {error['detail']}")
            time.sleep(int(response.headers.get("Retry-After", "5")))
        raise RuntimeError(f"giving up on {path}")


client = PacedClient(os.environ["BEYOND_TOKEN"])

listings = []
page = 1
while True:
    body = client.get(f"/listings/?page[size]=100&page[number]={page}")
    listings.extend(body["data"])
    if page >= body["meta"]["pagination"]["pages"]:
        break
    page += 1
```

---

## Error Handling

All error responses follow the JSON:API error format, providing structured information about what went wrong.

### Error Response Structure

```json
{
  "errors": [
    {
      "status": "400",
      "detail": "This field is required.",
      "source": {"pointer": "/data/attributes/email"},
      "code": "required"
    }
  ]
}
```


| Field            | Always present? | Description                                                                                                |
| ---------------- | --------------- | ---------------------------------------------------------------------------------------------------------- |
| `status`         | Yes             | HTTP status code as a string                                                                                 |
| `detail`         | Yes             | Specific explanation of what went wrong. Human-readable prose — the wording may change, so do not match on it |
| `source.pointer` | Yes             | JSON pointer to the request field that caused the error; `/data` when the error is not field-specific         |
| `code`           | Yes             | Stable, machine-readable identifier for the error — the field to branch on in code                            |
| `title`          | No              | Short, human-readable summary, on some errors only                                                           |
| `meta`           | No              | Extra context specific to the error, such as the channel or a request id                                     |

!!! warning "Branch on `status` and `code`, never on `title`"
    Whether `title` is present depends on where the error came from, which is an
    internal detail you cannot predict from the outside: errors raised by the
    API framework — authentication, permissions, parsing, validation, throttling
    — carry no `title`, while errors Beyond constructs itself (channel failures,
    gateway problems, some customization rejections) do. Treat it as a display
    nicety.

    `code` is the field to rely on. Every error carries one, so you can switch
    on it without a fallback. Codes documented on this page are stable; treat an
    unfamiliar one as generic for its `status` rather than failing on it, since
    new codes can appear as errors become more specific over time.


### Common Error Codes

#### 400 -- Bad Request

Malformed requests or request-body validation failures detected while parsing the
JSON:API document and validating request attributes. This includes both
structural issues and semantic or value validation errors raised while handling
the request body.

A request can also be rejected before it reaches an endpoint at all -- a body
larger than the API accepts, a form with too many fields, or an unrecognized
`Host` header. Those carry `code: "invalid"` and `source.pointer` of `/data`,
because no single field is at fault.

```json
{
  "errors": [
    {
      "status": "400",
      "detail": "This field is required.",
      "source": {"pointer": "/data/attributes/email"},
      "code": "required"
    }
  ]
}
```

When more than one request field is invalid, the response may include multiple
error objects. For example:

```json
{
  "errors": [
    {
      "status": "400",
      "detail": "Ensure this value is greater than or equal to 1.",
      "source": {"pointer": "/data/attributes/extra-guest-fee"},
      "code": "min_value"
    },
    {
      "status": "400",
      "detail": "Ensure this value is greater than or equal to 1.",
      "source": {"pointer": "/data/attributes/extra-guest-threshold"},
      "code": "min_value"
    }
  ]
}
```

Inside a list or a nested object, the pointer continues down to the field at
fault, using the same dasherized names as the request. An error about a list
item as a whole, such as a season overlapping an earlier one, points at the
item:

```json
{
  "errors": [
    {
      "status": "400",
      "detail": "overlaps the range at index 0",
      "source": {"pointer": "/data/attributes/seasonal-min-stays/1"},
      "code": "invalid"
    }
  ]
}
```

Invalid `sort` and `filter` parameters are also reported as `400`, with
`source.pointer` set to `/data` because the problem is in the query string
rather than the request body:

```json
{
  "errors": [
    {
      "status": "400",
      "detail": "invalid sort parameter: -base-price",
      "source": {"pointer": "/data"},
      "code": "invalid"
    }
  ]
}
```

**Token request errors:** When requesting a token at `/o/token/`, you may
receive an error if `user_id` references a user that doesn't exist or isn't
owned by your application, `credential_id` does not belong to the provided
`user_id`, `user_id` is provided with app-level scopes such as `user:write`, or
user-level scopes are requested without `user_id` when your application
requires user-scoped tokens. These return a standard OAuth2 error response, not
JSON:API format, with `"error": "invalid_scope"`.

#### Request ID Header

You may send an optional `X-Request-ID` header on any Public API request to make
log correlation easier when debugging with Beyond support.

- If you send a value made up of letters, digits, and the separators `-`, `_`,
`.`, or `:` (up to 128 characters — this covers UUIDs, ULIDs, hex strings, and
prefixed ids like `req_…`), the API uses that exact value.
- If you omit the header, or send it blank or whitespace-only, the API
generates a UUID for the request.
- If you send a value with characters outside that set or longer than 128
characters, the API does not reject the request — it falls back to a
server-generated UUID for that request. Your original value is not echoed back.
- Every response except the `/healthz` fast-path includes the effective
`X-Request-ID` header so you can log or report the exact value the server
used.

#### 401 -- Unauthorized

Missing or invalid authentication token.

```json
{
  "errors": [
    {
      "status": "401",
      "detail": "Authentication credentials were not provided.",
      "source": {"pointer": "/data"},
      "code": "not_authenticated"
    }
  ]
}
```

!!! tip
    If you receive a 401, check that your access token has not expired. Tokens are valid for 1 hour. See [Token Lifetimes](../getting-started/authentication.md#token-lifetimes).

#### 403 -- Forbidden

The token is valid but lacks the required scope for this operation, or the token is scoped to a different user.

```json
{
  "errors": [
    {
      "status": "403",
      "detail": "You do not have permission to perform this action.",
      "source": {"pointer": "/data"},
      "code": "permission_denied"
    }
  ]
}
```

Common causes:

- **Missing scope**: The token doesn't include the required scope for this endpoint.
- **User-scoped token restriction**: A user-scoped token attempted an app-level operation (e.g., creating or deleting a user).
- **Cross-user access**: A user-scoped token attempted to access resources belonging to a different user.
- **Source IP not allowed**: Your application has an IP allowlist configured and the request origin IP is not in that allowlist (applies to both `/o/`* OAuth2 endpoints and `/api/v1/*` resource endpoints).

#### 404 -- Not Found

The requested resource does not exist or is not accessible to your application.

A path that is not an endpoint at all -- a typo in the URL, or a resource id in
a segment that expects a number -- returns the same shape with the same
`not_found` code. Only `detail` distinguishes it from a resource you are not
allowed to see, so read the URL rather than branching on the wording.

```json
{
  "errors": [
    {
      "status": "404",
      "detail": "Listing 12345 not found or not owned by this application",
      "source": {"pointer": "/data"},
      "code": "not_found"
    }
  ]
}
```

#### 405 -- Method Not Allowed

The endpoint exists but does not support that HTTP method — for example a
`DELETE` on a resource that is read-only over the API.

```json
{
  "errors": [
    {
      "status": "405",
      "detail": "Method \"DELETE\" not allowed.",
      "source": {"pointer": "/data"},
      "code": "method_not_allowed"
    }
  ]
}
```

#### 409 -- Conflict

Three different situations produce a `409`, and they call for opposite responses —
one is a permanent defect in your request, the other two are temporary server
state. Tell them apart with `code`.

**`data.type` does not match the endpoint's resource type.** A JSON:API document
error, raised before any validation of your attributes runs — so a write can fail
this way even though every value in it is correct.

```json
{
  "errors": [
    {
      "status": "409",
      "detail": "The resource object's type (listing) is not the type that constitute the collection represented by the endpoint (base-price-customizations).",
      "source": {"pointer": "/data"},
      "code": "error"
    }
  ]
}
```

The `detail` names the type the endpoint expected — use it to correct the
payload. Resource types are **plural and dasherized** and do not always match
the URL path segment, so read the expected type from the endpoint's page or the
[Swagger UI](/api/v1/docs/) rather than deriving it from the path. See
[Resource types](jsonapi.md#key-conventions). Retrying the same document will
never succeed.

**A refresh is already running for the account** (`code: "sync_in_progress"`).
Returned when you ask a [managed account](../endpoints/managed-accounts.md) to
refresh its listings while a previous refresh is still in flight.

```json
{
  "errors": [
    {
      "status": "409",
      "detail": "A listings refresh is already in progress for this account. Please retry shortly.",
      "source": {"pointer": "/data"},
      "code": "sync_in_progress",
      "title": "Conflict",
      "meta": {"request_id": "0f1c1b2e-..."}
    }
  ]
}
```

This one **is** retryable: wait for the running refresh to finish rather than
issuing another. Nothing about your request needs to change.

**A concurrent operation on the same account blocked this request**
(`code: "concurrent_modification"`). Writes to the same
[managed account](../endpoints/managed-accounts.md) are applied one at a time.
A request that cannot take its turn before the server's wait bound — because
another create or delete of the same account or a credentials refresh is still
running — is rejected having changed nothing.

```json
{
  "errors": [
    {
      "status": "409",
      "detail": "Another operation on this account is in progress and this request timed out waiting for it, so it was not applied. Please retry shortly.",
      "source": {"pointer": "/data"},
      "code": "concurrent_modification",
      "title": "Conflict",
      "meta": {"request_id": "0f1c1b2e-..."}
    }
  ]
}
```

This one is retryable too: the request itself is fine, it just arrived while
the account was busy. Wait a moment and resend — the retry proceeds once the
concurrent operation finishes. Issuing one write at a time per account avoids
the wait entirely.

#### 422 -- Unprocessable Entity

The JSON:API document is structurally valid and request-body validation has
already passed, but the operation is still rejected during downstream domain or
integration processing. This includes channel-specific validation or
authentication failures that are not simple request-body field errors.

```json
{
  "errors": [
    {
      "status": "422",
      "detail": "Channel 'hostaway': Invalid client credentials",
      "source": {"pointer": "/data/attributes/credentials"},
      "code": "invalid_credentials",
      "title": "Invalid Credentials",
      "meta": {"channel": "hostaway"}
    }
  ]
}
```

Channel errors are the main place `title` and `meta` are populated: `title`
summarizes the channel's complaint and `meta.channel` names the channel that
raised it. The machine-readable identifier is the top-level `code`.

#### 415 -- Unsupported Media Type

The request body's `Content-Type` is not one the API accepts. Send
`application/vnd.api+json` (or `application/json`) on every request with a body.

```json
{
  "errors": [
    {
      "status": "415",
      "detail": "Unsupported media type \"text/plain\" in request.",
      "source": {"pointer": "/data"},
      "code": "unsupported_media_type"
    }
  ]
}
```

#### 429 -- Too Many Requests

Rate limit exceeded, or the account's monthly usage allocation is exhausted.
The two are distinguished by `code` — see
[Rate Limiting](rate-limiting.md#telling-the-two-429s-apart) for the full
comparison and for how to handle each.

#### 500 -- Internal Server Error

An unexpected server error. Your request reached Beyond and failed for a reason
that is not your request's fault, so nothing about it needs changing before you
retry. `code` is always `error`, and `meta.request_id` repeats the response's
`X-Request-ID` -- that is the value to quote to support.

```json
{
  "errors": [
    {
      "status": "500",
      "detail": "An unexpected error occurred and your request could not be completed. Please retry shortly; if the problem persists, contact support and quote the request id.",
      "source": {"pointer": "/data"},
      "code": "error",
      "title": "Internal Server Error",
      "meta": {"request_id": "0f1c1b2e-..."}
    }
  ]
}
```

Retry with backoff; if it persists, contact
[support@beyondpricing.com](mailto:support@beyondpricing.com) and quote the
request id.

#### 502 -- Bad Gateway

A service Beyond depends on to answer your request failed. Your request was
well-formed — nothing about it needs changing before you retry.

Two cases produce it. A **channel or PMS failure** during a write, such as
connecting a managed account — `meta.channel` names the channel and
`meta.request_id` is the value to quote to support:

```json
{
  "errors": [
    {
      "status": "502",
      "detail": "The 'hostaway' channel returned an unexpected error and your request could not be completed. Please retry shortly; if the problem persists, contact support and quote the request id.",
      "source": {"pointer": "/data"},
      "code": "bad_gateway",
      "title": "Channel Unavailable",
      "meta": {"channel": "hostaway", "request_id": "0f1c1b2e-..."}
    }
  ]
}
```

Or the **webhook-events service being unavailable** when reading
[webhook events](../endpoints/webhook-events.md):

```json
{
  "errors": [
    {
      "status": "502",
      "detail": "The webhook events service is temporarily unavailable.",
      "source": {"pointer": "/data"},
      "code": "error"
    }
  ]
}
```

Retry with exponential backoff. A `502` is transient by definition, but a
persistent one is worth reporting with the request id rather than retried
indefinitely.

### Best Practices

- **Check the `status` field** to determine the error category
- **Branch on `code`, not `title` or `detail`**: `code` is the stable
  machine-readable identifier. `title` is absent from most errors and `detail`
  is prose whose wording can change
- **Use `source.pointer`** to identify which request field caused validation
  errors. It is always present, so no presence check is needed; `/data` means
  the error is not tied to a single field
- **Expect multiple error objects** when more than one request attribute is invalid
- **Log the full error response** along with the response's `X-Request-ID` — that
  is the value support needs to trace the request
- **Implement retry logic** with exponential backoff for 429 and 5xx errors
- **Do not retry** 400, 401, 403, 404, 405, 415, or 422 errors without changing
  the request. The exceptions among the 4xx are a `409` with
  `code: "sync_in_progress"` or `code: "concurrent_modification"`, both of which
  clear on their own

---

## API Versioning

The Beyond API uses URL-based versioning to provide a stable integration surface while allowing the API to evolve.

### Versioning Scheme

The API version is embedded in the URL path:

```
$BASE_URL/api/v1/listings/
              ^^
           version
```

The current version is **v1**. When a new version is released, it will be available at `/api/v2/` while the previous version continues to operate.

### When a New Version Is Created

A new API version is created when a change would **break existing integrations**. Breaking changes include:

- **Removing an endpoint** or HTTP method
- **Removing a response field** or changing its type
- **Removing or renaming a query parameter**
- **Changing the meaning of an existing field** (e.g., a field that was a string becoming an integer)
- **Changing authentication or authorization requirements** in a way that invalidates existing tokens or scopes
- **Changing error response codes** for existing error conditions (e.g., a 400 becoming a 422)
- **Changing the default behavior** of an endpoint (e.g., default sort order, default page size)

### Non-Breaking Changes (No New Version)

The following changes are considered **backward-compatible** and will be made to the current version without creating a new one:

- **Adding a new endpoint**
- **Adding a new optional query parameter**
- **Adding a new field to a response** (your client should ignore unknown fields)
- **Adding a new optional field to a request body**
- **Adding a new scope**
- **Adding a new error code** for a previously-unhandled condition
- **Improving error messages** (the `detail` text may change)
- **Performance improvements** or bug fixes

!!! warning
    Your integration should be built to tolerate additive changes. Always ignore unknown fields in responses rather than failing on them.

### Webhook Events

**The API version and a webhook event's schema version are independent contracts.**
Bumping one never implies bumping the other.

Beyond's [events](../webhooks/index.md) are *thin*: `data.attributes` carries facts about
something that happened (`status`, `completed-at`, `base-price`), and the domain object it
happened to appears only as a relationship — a `{type, id}` pair plus a `links.related`
URL you follow to fetch the current state. **No event embeds an API resource
representation.** So if `GET /listings/` renames a field in `/api/v2/`, no event body
changes: the event never contained a listing in the first place.

Two consequences worth stating explicitly:

- **One thing happening emits exactly one event**, regardless of which API version
  triggered it. If you create a managed account through `/api/v1/` or a future `/api/v2/`,
  you receive a single `account.created`. Webhook subscriptions are configured per
  application, not per API version, and the `webhook-id` idempotency contract depends on
  one occurrence producing one identifier.
- **An event can fire with no API request behind it at all.**
  `listing.base_price_changed` is emitted whenever a base price changes on *any* Beyond
  surface — the web app, an internal pricing job, a PMS sync. There is frequently no
  triggering API version to speak of.

#### How events are versioned

**The version lives in the event type, not the envelope.** There is no
`meta.schema-version`, `meta.api-version`, or any other version field in a webhook
payload. The identity of an event *is* its type, so a change that a consumer cannot absorb
becomes a new type.

- **Additive changes never create a new type.** A new attribute or a new optional
  relationship is backward-compatible. You must ignore fields you do not recognize, exactly
  as you would for a REST response body.
- **A breaking change ships as a new event type** with a version suffix — for example
  `listing.created.v2` alongside `listing.created`. Removing or renaming an attribute,
  changing its type, or changing what an existing field means are all breaking.
- **The two run in parallel.** When a `v2` of an event is introduced, the original keeps
  firing. You migrate your handler for that one event type on your own schedule, test it in
  isolation, and can fall back to the old type if the upgrade misbehaves — with no effect
  on any other event. The original type is then deprecated and eventually retired following
  the [Deprecation Policy](#deprecation-policy) below: announced ahead of time, run in
  parallel through the migration window, then stopped.

This is deliberately *per-event* versioning, not a global one. A `v2` of `listing.created`
does not touch `account.created` or any other event, so you are never forced to re-test
every handler because one payload changed.

!!! warning "A new enum value is the classic example"
    Adding a value to a constrained attribute — for example a third `status` beyond
    `succeeded` and `failed` — looks additive but breaks a consumer that exhaustively
    matches on the current values. This is exactly the kind of change that warrants a new
    event type. Even so, **treat unrecognized enum values as a case to handle rather than an
    error**, so a future value cannot take your handler down before you have migrated.

#### What your consumer must tolerate

To stay compatible across every non-breaking change we make, your handler must:

1. **Ignore unknown fields** in `data.attributes` and `data.relationships`.
2. **Ignore unknown enum values** rather than crash on them (see the warning above).
3. **Ignore event types you do not recognize.** Webhook subscriptions are per application,
   not per event type — so when we introduce a new event type (including a `.v2` of one you
   already handle), it is delivered to your endpoint immediately. Route on `meta.type` and
   silently acknowledge (`2xx`) any type you have no handler for, so a new event never
   causes a failed delivery and a retry storm.

#### The one place the two versions touch

`links.related` URLs point back into the API, and today they are `/api/v1/` paths:

```json
"links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
```

**Treat this URL as a stable locator, not a version declaration.** Because our events are
thin, the link never carried a versioned *representation* — it only tells you *where* the
object lives. The `v1` in it is a routing prefix, not a promise about the payload you get
back; the shape of that payload is governed by the API version you *request* when you follow
the link, exactly as it is for any other call you make.

So the link does not need to track the newest API version, and it will not be rewritten per
consumer. **`links.related` stays on `/api/v1/` even after `/api/v2/` ships**, and a partner
who has otherwise migrated to `/api/v2/` can still follow it — a `GET` on a `/api/v1/` URL
keeps returning the object for as long as v1 is Active.

### Deprecation Policy

When a new version is released:

1. **Both versions run in parallel** for a migration period
2. **Deprecation notice** is communicated via email and documented here
3. **Migration guide** is provided with a mapping of changes between versions
4. **Sunset date** is announced at least 6 months in advance

### Version Lifecycle

| Phase | Description |
|-------|-------------|
| **Active** | Fully supported, receives bug fixes and non-breaking enhancements |
| **Deprecated** | Still operational, but a newer version is available. No new features. |
| **Sunset** | Decommissioned. Requests return `410 Gone`. |

The current version, **v1**, is **Active**.

---

---
description: >-
  Summary of every Beyond API v1 endpoint with method, OAuth2 scope, and token tier, plus links to the interactive API reference.
---

## API Endpoints

This section documents all available Beyond API v1 endpoints. For full request/response schemas with the ability to make live calls, use the interactive API reference tools:

| Tool | URL | Description |
| ---- | --- | ----------- |
| **Swagger UI** | [`/api/v1/docs/`](/api/v1/docs/) | Interactive API explorer -- browse endpoints, try requests, view schemas |
| **ReDoc** | [`/api/v1/redoc/`](/api/v1/redoc/) | Clean, readable API reference |
| **OpenAPI Schema** | [`/api/v1/schema/`](/api/v1/schema/) | Downloadable OpenAPI 3.0 spec (YAML) for code generation |

To download the schema for use with code generators:

```bash
curl -o openapi.yaml $BASE_URL/api/v1/schema/
```

### Endpoint Summary

| Endpoint | Method | Scope | Token Tier | Description |
| -------- | ------ | ----- | ---------- | ----------- |
| [`/api/v1/listings/`](listings.md) | GET | `listings:read` | Both | List all listings |
| [`/api/v1/listings/<id>/`](listings.md#get-listing-details) | GET | `listings:read` | Both | Get listing details |
| [`/api/v1/listings/<id>/activation/`](listings.md#listing-activation) | PATCH | `listings:write` | Both | Enable or disable price syncing for a listing |
| [`/api/v1/listings/<id>/refresh/`](listings.md#listing-refresh) | POST | `listings:write` | Both | Queue a reservations refresh for a listing |
| [`/api/v1/listings/<id>/calendar/`](calendar.md) | GET | `reservations:read` | Both | Get listing calendar |
| [`/api/v1/compsets/`](compsets.md) | GET | `compsets:read` | Both | List the comp sets you can see (paginated summary) |
| [`/api/v1/compsets/<id>/`](compsets.md) | GET | `compsets:read` | Both | Get one comp set, fully enriched |
| [`/api/v1/listings/<id>/market-insights/`](insights.md) | GET | `insights:read` | Both | Compare a listing's daily performance with its neighborhood benchmark |
| [`/api/v1/listings/<id>/recommendations/`](recommendations.md) | GET | `listings:read` | Both | List recommendations for a listing |
| [`/api/v1/listings/<id>/customizations/`](listing-customizations.md#all-customizations) | GET | `listings:read` | Both | Get all customizations for a listing in one response |
| [`/api/v1/listings/<id>/customizations/base-price/`](listing-customizations.md#base-price) | GET | `listings:read` | Both | Get base price customization for a listing |
| [`/api/v1/listings/<id>/customizations/base-price/`](listing-customizations.md#base-price) | PATCH | `listings:write` | Both | Update base price customization for a listing |
| [`/api/v1/listings/<id>/customizations/min-stays/`](listing-customizations.md) | GET | `listings:read` | Both | Get min-stays customization for a listing |
| [`/api/v1/listings/<id>/customizations/min-stays/`](listing-customizations.md) | PATCH | `listings:write` | Both | Update min-stays customization for a listing |
| [`/api/v1/listings/<id>/customizations/extra-guest-fees/`](listing-customizations.md#extra-guest-fees) | GET | `listings:read` | Both | Get extra guest fee customization for a listing |
| [`/api/v1/listings/<id>/customizations/extra-guest-fees/`](listing-customizations.md#extra-guest-fees) | PATCH | `listings:write` | Both | Update extra guest fee customization for a listing |
| [`/api/v1/listings/<id>/customizations/min-max-prices/`](listing-customizations.md) | GET | `listings:read` | Both | Get min/max prices customization for a listing |
| [`/api/v1/listings/<id>/customizations/min-max-prices/`](listing-customizations.md) | PATCH | `listings:write` | Both | Update min/max prices customization for a listing |
| [`/api/v1/listings/<id>/customizations/time-based-adjustments/`](listing-customizations.md) | GET | `listings:read` | Both | Get time-based adjustments customization for a listing |
| [`/api/v1/listings/<id>/customizations/time-based-adjustments/`](listing-customizations.md) | PATCH | `listings:write` | Both | Update time-based adjustments customization for a listing |
| [`/api/v1/listings/<id>/customizations/manual-overrides/`](listing-customizations.md#manual-overrides) | GET | `listings:read` | Both | Get manual price overrides for a listing |
| [`/api/v1/listings/<id>/customizations/manual-overrides/`](listing-customizations.md#manual-overrides) | PATCH | `listings:write` | Both | Update manual price overrides for a listing |
| [`/api/v1/listings/<id>/customizations/gap-price-adjustments/`](listing-customizations.md#gap-price-adjustments) | GET | `listings:read` | Both | Get gap price adjustments customization for a listing |
| [`/api/v1/listings/<id>/customizations/gap-price-adjustments/`](listing-customizations.md#gap-price-adjustments) | PATCH | `listings:write` | Both | Update gap price adjustments customization for a listing |
| [`/api/v1/users/`](users.md) | GET | `user:read` | Both | List users |
| [`/api/v1/users/`](users.md#create-user) | POST | `user:write` | App-level only | Create a user |
| [`/api/v1/users/<id>/`](users.md) | GET | `user:read` | Both | Retrieve a user |
| [`/api/v1/users/<id>/credentials/`](users.md#list-user-credentials) | GET | `user:read` | Both | List credentials for a user |
| [`/api/v1/users/<id>/`](users.md#delete-user) | DELETE | `user:write` | App-level only | Delete a user |
| [`/api/v1/users/<id>/accounts/`](managed-accounts.md) | GET | `user:read` | Both | List accounts for a user |
| [`/api/v1/users/<id>/accounts/`](managed-accounts.md#add-account) | POST | `user:write` | Both | Add an account for a user |
| [`/api/v1/users/<id>/accounts/<id>/`](managed-accounts.md) | GET | `user:read` | Both | Get one account for a user |
| [`/api/v1/users/<id>/accounts/<id>/refresh/`](managed-accounts.md#refresh-account) | POST | `user:write` | Both | Queue a full refresh for an account |
| [`/api/v1/users/<id>/accounts/<id>/`](managed-accounts.md#delete-account) | DELETE | `user:write` | Both | Delete an account |
| [`/api/v1/webhook-configuration/`](webhook-configuration.md) | GET | `webhooks:read` | Both | Read your webhook destination and event selection |
| [`/api/v1/webhook-configuration/`](webhook-configuration.md#choosing-your-events) | PATCH | `webhooks:write` | App-level only | Change your webhook destination or event selection |
| [`/api/v1/webhook-events/`](webhook-events.md) | GET | `webhooks:read` | Both | List the webhook events delivered to you |
| [`/api/v1/webhook-events/<msg_id>/`](webhook-events.md#retrieve-a-webhook-event) | GET | `webhooks:read` | Both | Retrieve one delivered event, including its body |

**Token Tier key:**

- **App-level only**: Requires an application-level token (no `user_id`).
- **Both**: Works with either an app-level token (all resources) or a user-scoped token (bound user's resources only).

See [User-Scoped Tokens](../getting-started/authentication.md#user-scoped-tokens) for details.

### Common Patterns

All endpoints follow the same conventions:

- **Authentication**: OAuth2 Bearer token in the `Authorization` header
- **Content-Type**: `application/vnd.api+json`
- **Response format**: [JSON:API](../guides/jsonapi.md)
- **Pagination**: [Page-based](../guides/jsonapi.md#pagination) with `page[number]` and `page[size]` (defaults and maximums vary by endpoint)
- **Errors**: Structured [JSON:API error responses](../guides/error-handling.md)

---

---
description: >-
  Beyond API listings endpoint: retrieve and manage the properties Beyond prices, with pricing data and links to calendars, customizations, and insights.
---

## Listings

Listings are the properties Beyond prices. Every other listing-scoped resource
— [calendar](calendar.md), [recommendations](recommendations.md),
[customizations](listing-customizations.md), [market insights](insights.md) —
hangs off a listing id from this endpoint.

| Endpoint | Method | Scope | Description |
| -------- | ------ | ----- | ----------- |
| `/api/v1/listings/` | GET | `listings:read` | List all listings (paginated) |
| `/api/v1/listings/<id>/` | GET | `listings:read` | Get listing details |
| `/api/v1/listings/<id>/activation/` | PATCH | `listings:write` | Enable or disable price syncing for a listing |
| `/api/v1/listings/<id>/refresh/` | POST | `listings:write` | Queue a full refresh (listing details and reservations) for a listing |
| `/api/v1/listings/<id>/customizations/<type>/` | GET, PATCH | `listings:read`, `listings:write` | Retrieve or update listing customizations |

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Listings).

### List Listings

Returns a paginated list of all listings for the authenticated application.

A listing appears here only while it has at least one active channel listing —
a live connection to the channel or PMS the property is syndicated to. Beyond
neither prices nor syncs a listing without one, so it is invisible to the whole
API: every listing-scoped endpoint answers `404` for it, exactly as it does for
a listing your application does not own.

#### Request

```
GET /api/v1/listings/
    ?filter[enabled]=true
    &sort=-created-at
    &page[size]=25
```

| Parameter | Description |
| --------- | ----------- |
| `filter[owner]` | Only listings owned by this user id. |
| `filter[enabled]` | Only listings with price syncing enabled (`true`) or disabled (`false`). |
| `filter[name]` | Only listings whose title contains this text (case-insensitive). |
| `filter[market]` | Only listings in this Beyond market (case-insensitive exact match on the `market` attribute, e.g. `San Francisco`). |
| `filter[bedrooms]` | Only listings with exactly this bedroom count. |
| `filter[in-active-market]` | Only listings that are fully priceable (`true`) or not (`false`). Same semantics as the `in-active-market` attribute. |
| `sort` | Sort field: `created-at`, `title`, or `city`. Prefix with `-` for descending. Default `-created-at` (newest first). |
| `include` | Sideload related resources: `owner`. See [Compound documents](#compound-documents-sideloading). |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **25**, maximum **100**. |

#### Response

```json
{
  "data": [
    {
      "type": "listings",
      "id": "123",
      "attributes": {
        "title": "Sunny 2BR near the beach",
        "image": "https://images.example.com/listing-123.jpg",
        "neighborhood": "Mission District",
        "city": "San Francisco",
        "state": "CA",
        "country": "US",
        "market": "San Francisco",
        "room-type": "entire_home",
        "bedrooms": 2,
        "bathrooms": "1.50",
        "base-price": 189,
        "base-price-updated-at": "2026-07-02T09:15:00Z",
        "min-price": 95,
        "min-price-updated-at": "2026-07-02T09:15:00Z",
        "max-price": null,
        "min-stay": 2,
        "extra-guest-fee": 25,
        "extra-guest-threshold": 4,
        "latitude": "37.77490000",
        "longitude": "-122.41940000",
        "timezone": "America/Los_Angeles",
        "currency": "USD",
        "in-active-market": true,
        "enabled": true,
        "address": "123 Main St, San Francisco, CA",
        "created-at": "2026-05-20T18:03:11Z",
        "channel-listings": [
          {"channel": "airbnb", "channel-id": "987654321"}
        ]
      },
      "relationships": {
        "owner": {"data": {"type": "users", "id": "456"}}
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

#### Listing attributes

| Field | Description |
| ----- | ----------- |
| `title`, `image`, `address`, `neighborhood`, `city`, `state`, `country` | Descriptive metadata synced from the channel/PMS. |
| `market` | The Beyond market the listing belongs to (e.g. `San Francisco`); `null` while unassigned. Filterable via `filter[market]`. |
| `room-type` | Property type as reported by the channel (e.g. `entire_home`). |
| `bedrooms` | Bedroom count. |
| `bathrooms` | Bathroom count, as a decimal string — `"1.50"` is one full and one half bath. |
| `base-price` | The listing's nightly base price in `currency` — the anchor Beyond's algorithm prices around. Managed via the [base-price customization](listing-customizations.md#base-price). |
| `base-price-updated-at`, `min-price-updated-at` | When the respective price was last changed. |
| `min-price`, `max-price` | The listing-level nightly floor and ceiling in `currency`. `max-price` is `null` when no ceiling is set. Managed via the [min-max-prices customization](listing-customizations.md#minmax-prices). |
| `min-stay` | Default minimum stay, in nights. |
| `extra-guest-fee`, `extra-guest-threshold` | Per-guest fee in `currency` charged above the included guest count. |
| `latitude`, `longitude` | Coordinates, as decimal strings. |
| `timezone` | IANA timezone name. Date defaults on the [calendar](calendar.md) endpoint are computed in this timezone. |
| `currency` | ISO 4217 code the listing's prices are quoted in. Not necessarily the owner's billing currency, which is what [market insights](insights.md) rates use. |
| `in-active-market` | Whether the listing is fully priceable. See [Pricing readiness](#pricing-readiness-in-active-market). |
| `enabled` | Whether price syncing is on. Change it via [listing activation](#listing-activation). |
| `created-at` | When the listing was created in Beyond. |
| `channel-listings` | The active channel listings linked to this listing, each with its `channel` (e.g. `airbnb`) and channel-side `channel-id`. |

### Get Listing Details

Returns detailed information about a specific listing. The detail response
carries every list attribute above plus `sync-status`:

```json
"sync-status": {
  "state": "completed",
  "last-successful-sync-at": "2026-08-09T04:12:44Z"
}
```

| Field | Description |
| ----- | ----------- |
| `sync-status.state` | Sync state of the primary channel listing: `queued`, `in_progress`, `completed`, or `unknown`. |
| `sync-status.last-successful-sync-at` | Timestamp of the last successful listing sync; `null` if never synced. |

#### Pricing Readiness (`in-active-market`)

The `in-active-market` attribute tells you whether the listing is fully
priceable: Beyond has assigned it to a **pricing cluster** and the cluster's
market is active. Most listings are assigned a cluster as part of the initial
import; occasionally one is created without a cluster, and `in-active-market`
stays `false` until Beyond assigns one. A common cause is a wrong or imprecise
listing address that lands the property outside any area Beyond covers (for
example in the middle of the ocean) — correcting the address in the PMS lets
the next sync cluster it. While the listing has no cluster, the
[Calendar](calendar.md) endpoint returns a `400` ("not yet clustered"). When
`in-active-market` is `true`, the calendar endpoint is always available for
the listing.

Once a listing has a base price, every later change of `in-active-market` —
in either direction — is announced by the
[`listing.in_active_market_changed`](../webhooks/in-active-market-changed.md)
webhook, so you never need to poll for it.

#### Compound Documents (Sideloading)

Include related resources in a single request using the `?include=` parameter:

- `owner` -- the user who owns the listing

This avoids a separate API call to fetch the owner. See the [JSON:API guide](../guides/jsonapi.md#compound-documents-sideloading) for details on how compound documents work.

### Listing Activation

Use the listing activation endpoint to enable or disable price syncing for a listing.

Newly synced listings are created disabled, so this is the endpoint used in the
final step of the [partner onboarding flow](../getting-started/partners.md#step-7-enable-a-listing).

#### Request

```
PATCH /api/v1/listings/123/activation/
```

```json
{
  "data": {
    "type": "listing-activations",
    "id": "123",
    "attributes": {
      "enabled": true,
      "base-price": 189,
      "min-price": 95
    }
  }
}
```

| Field | Description |
| ----- | ----------- |
| `enabled` | Required. Whether price syncing is enabled for the listing. |
| `base-price` | Nightly base price to apply with the activation, in the listing's `currency`. Minimum `10`. **Required when enabling** a listing that has no base price yet — omitting it is a `422` with code `required`. |
| `min-price` | Minimum nightly price to apply with the activation. Minimum `5`. |

The response echoes the resulting activation state (`enabled`, `base-price`,
`min-price`). Sending the state the listing is already in returns
`304 Not Modified` with no body.

### Listing Refresh

Use the listing refresh endpoint to enqueue an asynchronous full refresh for a listing's
primary channel listing. This refreshes the listing's details and availability, then its
reservations, and finally recomputes the automatic base price.

#### Request

```
POST /api/v1/listings/123/refresh/?sync-priority=normal
```

No request body. The endpoint accepts one optional query parameter:

| Parameter | Description |
| --------- | ----------- |
| `sync-priority` | Optional. `high` (the default) or `normal`. See [Sync priority](#sync-priority) below. Any other value is a `400`. |

The endpoint returns immediately with `202 Accepted` once the jobs are queued:

```json
{
  "meta": {
    "status": "accepted",
    "message": "Listing refresh queued"
  },
  "links": {
    "related": "$BASE_URL/api/v1/listings/123/"
  }
}
```

When the refresh chain finishes (or fails), the
[`listing.refreshed`](../webhooks/listing-refreshed.md) webhook reports the
outcome — no polling needed.

#### Sync priority

The `sync-priority` query parameter tells Beyond how soon the refresh should start.

| Value | Effect | Use it when |
| ----- | ------ | ----------- |
| `high` | Beyond starts the refresh as soon as it can. | Someone is waiting on the result, for example a user who just changed the listing in your PMS. |
| `normal` | The refresh can take longer to start when Beyond is busy. | You refresh many listings at once and none of them is urgent. |

The parameter is optional. A request that leaves it out is treated as `high`.

Priority affects only how soon the refresh starts. Either value refreshes the same data and fires the same [`listing.refreshed`](../webhooks/listing-refreshed.md) webhook.

### Listing Customizations

Use the [Listing Customizations](listing-customizations.md) endpoints to retrieve and
update pricing and stay-rule settings for a specific listing.

Supported customization types include:

- `base-price`
- `min-stays`
- `extra-guest-fees`
- `min-max-prices`
- `time-based-adjustments`
- `manual-overrides`

You can also read every customization in one request with
[`GET /api/v1/listings/<id>/customizations/`](listing-customizations.md#all-customizations).

### Use cases

1. **Initial import.** Page through `GET /listings/` after onboarding a user to
   discover the listings Beyond synced from their PMS, then store the ids for
   the per-listing endpoints.
2. **Enable pricing selectively.** Filter with `filter[enabled]=false`, review
   each listing's `base-price`, and PATCH its activation once the price is
   agreed with the owner.
3. **Readiness gate.** Before calling the [calendar](calendar.md) endpoint,
   check `in-active-market` — `false` predicts the calendar's
   "not yet clustered" `400`.
4. **Reconciliation.** Match your PMS records to Beyond through
   `channel-listings` (`channel` + `channel-id`) rather than by title.

### Errors

| Status | Cause |
| ------ | ----- |
| `304` | Activation PATCH that does not change `enabled`. |
| `400` | Malformed filter, sort, or request body, or an unknown `sync-priority` on a refresh. |
| `401` | Missing or invalid bearer token. |
| `403` | Token lacks the required scope (`listings:read` / `listings:write`). |
| `404` | No listing with that id is visible to your application. |
| `422` | Activation enabling a listing without a `base-price` (code `required`), or a customization write the pricing engine rejects. |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

---

## Listing Customizations

| Endpoint | Method | Scope | Description |
| -------- | ------ | ----- | ----------- |
| `/api/v1/listings/<id>/customizations/` | GET | `listings:read` | Get all customizations for a listing in one response |
| `/api/v1/listings/<id>/customizations/base-price/` | GET | `listings:read` | Get base price customization for a listing |
| `/api/v1/listings/<id>/customizations/base-price/` | PATCH | `listings:write` | Update base price customization for a listing |
| `/api/v1/listings/<id>/customizations/min-stays/` | GET | `listings:read` | Get min-stays customization for a listing |
| `/api/v1/listings/<id>/customizations/min-stays/` | PATCH | `listings:write` | Update min-stays customization for a listing |
| `/api/v1/listings/<id>/customizations/extra-guest-fees/` | GET | `listings:read` | Get extra guest fee customization for a listing |
| `/api/v1/listings/<id>/customizations/extra-guest-fees/` | PATCH | `listings:write` | Update extra guest fee customization for a listing |
| `/api/v1/listings/<id>/customizations/min-max-prices/` | GET | `listings:read` | Get min/max prices customization for a listing |
| `/api/v1/listings/<id>/customizations/min-max-prices/` | PATCH | `listings:write` | Update min/max prices customization for a listing |
| `/api/v1/listings/<id>/customizations/time-based-adjustments/` | GET | `listings:read` | Get time-based adjustments customization for a listing |
| `/api/v1/listings/<id>/customizations/time-based-adjustments/` | PATCH | `listings:write` | Update time-based adjustments customization for a listing |
| `/api/v1/listings/<id>/customizations/manual-overrides/` | GET | `listings:read` | Get manual price overrides for a listing |
| `/api/v1/listings/<id>/customizations/manual-overrides/` | PATCH | `listings:write` | Update manual price overrides for a listing |
| `/api/v1/listings/<id>/customizations/occupancy-based-pricing/` | GET | `listings:read` | Get occupancy-based pricing customization for a listing |
| `/api/v1/listings/<id>/customizations/occupancy-based-pricing/` | PATCH | `listings:write` | Update occupancy-based pricing customization for a listing |
| `/api/v1/listings/<id>/customizations/checkin-checkout-days/` | GET | `listings:read` | Get check-in/check-out days customization for a listing |
| `/api/v1/listings/<id>/customizations/checkin-checkout-days/` | PATCH | `listings:write` | Update check-in/check-out days customization for a listing |
| `/api/v1/listings/<id>/customizations/gap-price-adjustments/` | GET | `listings:read` | Get gap price adjustments customization for a listing |
| `/api/v1/listings/<id>/customizations/gap-price-adjustments/` | PATCH | `listings:write` | Update gap price adjustments customization for a listing |

!!! tip "Try it out"
    Explore schemas and live requests in the [Swagger UI](/api/v1/docs/#/Customizations).

### Overview

Listing customization endpoints expose the mutable pricing and stay-rule settings that
apply to a specific listing. These routes use a stable `customizations/` namespace so
new customization types can be added later without changing the API version.

These endpoints are listing-scoped. A future release may add a separate namespace for
global default customizations.

---

### Supported Customization Types

- `base-price`: the listing's nightly base price.
- `min-stays`: minimum-night rules for annual, seasonal, day-of-week, lead-time, and gap-fill behavior.
- `extra-guest-fees`: per-guest fees that apply when a reservation exceeds the included guest count.
- `min-max-prices`: nightly and monthly floor and ceiling pricing, including weekday and seasonal overrides.
- `time-based-adjustments`: lead-time discounts and premiums, either dynamic or manually configured.
- `manual-overrides`: per-date price overrides that pin or adjust the price for specific dates.
- `occupancy-based-pricing`: fine-tuning from the listing's occupancy compared with its market, with optional caps and excluded date ranges.
- `checkin-checkout-days`: the weekdays guests may check in and check out, year-round and per season.
- `gap-price-adjustments`: discounts and premiums for the nights of short open gaps between bookings, either dynamic or manually configured.

---

### Conventions

- Use `GET` to retrieve the current customization state for a listing.
- Use `PATCH` to update only the fields you want to change.
- Request and response bodies follow [JSON:API](../guides/jsonapi.md).
- Field names are dasherized in the HTTP payloads.
- Every item of a seasonal list (`seasonal-*`) has a `start-date`, an
  `end-date`, and a `rollover` flag. Set `rollover` to `true` to repeat the
  season every year on the same dates; a repeating range must be shorter than
  one year, and its dates are reported in the current year. A seasonal list,
  when sent, replaces the listing's whole list, repeating seasons included, so
  the body of a `GET` can be sent back unchanged.

---

### All Customizations

`GET /api/v1/listings/<id>/customizations/` returns every supported
customization for a listing in a single `listing-customizations` resource,
so partners can retrieve the full picture without one request per type.

The response is keyed by customization name, and each value is identical to
the `attributes` returned by that customization's own `GET` endpoint:

- `base-price`
- `extra-guest-fees`
- `min-max-prices`
- `min-stays`
- `time-based-adjustments`
- `occupancy-based-pricing`
- `checkin-checkout-days`
- `gap-price-adjustments`

To retrieve only a subset, use the JSON:API
[sparse fieldset](https://jsonapi.org/format/#fetching-sparse-fieldsets)
query parameter `fields[listing-customizations]` with a comma-separated
list of customization names. For example:

```text
GET /api/v1/listings/123/customizations/?fields[listing-customizations]=base-price,min-stays
```

returns only the `base-price` and `min-stays` customizations. This endpoint
is read-only; continue to use the individual `customizations/<type>/`
endpoints with `PATCH` to make changes.

`manual-overrides` is deliberately not part of this aggregate: it is keyed by
date rather than by rule, so a single listing can carry hundreds of rows. Read
it from its own endpoint, where you can also narrow the date range.

---

### Base Price

Use `base-price` to set the listing's nightly base price. The base price is the
anchor used by Beyond Pricing's algorithm; minimum and maximum prices, seasonal
rules, and time-based adjustments are all applied relative to it.

The payload includes:

- `base-price`: the nightly base price for the listing, in the listing's currency. Must be at least `10`.

```json
{
  "data": {
    "type": "base-price-customizations",
    "id": "123",
    "attributes": {
      "base-price": 189
    }
  }
}
```

---

### Extra Guest Fees

Use `extra-guest-fees` to charge an additional fee when a reservation exceeds the
number of guests included in the standard rate.

If no extra guest fee is configured, the standard price applies.

The payload includes:

- `extra-guest-fee`: the fee charged for each guest above the threshold, in the listing's currency.
- `extra-guest-threshold`: the number of guests included before the fee applies.

```json
{
  "data": {
    "type": "extra-guest-fee-customizations",
    "id": "123",
    "attributes": {
      "extra-guest-fee": 25,
      "extra-guest-threshold": 4
    }
  }
}
```

---

### Minimum Stays

Use `min-stays` to manage minimum-night requirements for a listing. This
customization supports a combination of year-round defaults, weekday overrides,
seasonal rules, lead-time rules, and gap-fill behavior.

Minimum stays are applied in the following order:

1. Seasonal Time-Based Minimum Stays
2. Time-Based Minimum Stays
3. Seasonal Minimum Stays
4. Day of Week Minimum Stays
5. Annual Minimum Stays

Gap fills apply across minimum stay rules to help protect short orphan gaps in
availability.

A `GET` returns every rule family. Unconfigured families come back as empty
lists. This example shows each family configured:

```json
{
  "data": {
    "type": "min-stay-customizations",
    "id": "123",
    "attributes": {
      "min-stay": 2,
      "gap-fill-min-stay": {"enabled": true, "gaps": 3, "buffer": 1},
      "seasonal-min-stays": [
        {"start-date": "2026-12-20", "end-date": "2027-01-05",
         "rollover": true, "min-stay": 5, "min-stay-locked": false}
      ],
      "last-minute-min-stays": [
        {"days-away": 7, "direction": "within", "min-stay": 1}
      ],
      "day-of-week-min-stays": [
        {"weekday": "monday", "min-stay": null},
        {"weekday": "tuesday", "min-stay": null},
        {"weekday": "wednesday", "min-stay": null},
        {"weekday": "thursday", "min-stay": null},
        {"weekday": "friday", "min-stay": 2},
        {"weekday": "saturday", "min-stay": 2},
        {"weekday": "sunday", "min-stay": null}
      ],
      "seasonal-day-of-week-min-stays": [
        {"start-date": "2026-08-28", "end-date": "2026-08-30",
         "rollover": false,
         "min-stays": [
           {"weekday": "monday", "min-stay": null},
           {"weekday": "tuesday", "min-stay": null},
           {"weekday": "wednesday", "min-stay": null},
           {"weekday": "thursday", "min-stay": null},
           {"weekday": "friday", "min-stay": null},
           {"weekday": "saturday", "min-stay": null},
           {"weekday": "sunday", "min-stay": 2}
         ]}
      ],
      "seasonal-gap-fill-min-stays": [
        {"start-date": "2026-07-01", "end-date": "2026-07-31",
         "rollover": true,
         "gap-fill-min-stay": {"enabled": true, "gaps": 1, "buffer": 2}}
      ],
      "seasonal-time-based-min-stays": [
        {"start-date": "2027-01-01", "end-date": "2027-01-22",
         "rollover": false, "exclude": false,
         "time-based-min-stays": [
           {"days-away": 3, "direction": "within", "min-stay": 2}
         ]},
        {"start-date": "2027-04-01", "end-date": "2027-04-15",
         "rollover": false, "exclude": true}
      ]
    }
  }
}
```

#### Annual Minimum Stay

Use `min-stay` to set the default minimum stay across all dates, except where a
day-of-week or seasonal rule overrides it. We recommend setting this to the
shortest minimum stay you would accept during lower-demand periods.

If the annual minimum stay is left blank, it defaults to a 1-night stay.

#### Annual Minimum Stays By Day Of Week

Use `day-of-week-min-stays` to set year-round minimum stays for specific
weekdays. These values take precedence over the annual minimum stay. Days set to
`null` inherit the annual minimum stay value.

#### Seasonal Minimum Stays By Day Of Week

Use `seasonal-day-of-week-min-stays` to set weekday minimum stays for a date
range. Each entry carries a `min-stays` list with all seven weekdays; days set
to `null` fall back to the lower-precedence rules. Seasonal weekday minimum
stays override annual rules and annual weekday rules for their date range. They
are still superseded by seasonal minimum stays, time-based minimum stays, and
gap-fill rules.

#### Seasonal Minimum Stays

Use `seasonal-min-stays` to create date-range specific rules for holidays,
events, or other periods of predictable demand. Set `rollover` to `true` to
repeat the rule every year on the same dates; a repeating range must be shorter
than one year. Set `min-stay-locked` to `true` to protect the value from
automatic changes. These rules override annual and day-of-week minimum
stays, but time-based minimum stays and gap fills still take precedence.

#### Time-Based Minimum Stays

Use `last-minute-min-stays` to set a minimum stay based on how far away a
booking is from today. Each rule carries `days-away`, a `direction` (`within`
for dates within that many days, `after` for dates at least that many days
away), and the `min-stay` to require. These rules take precedence over seasonal
minimum stays.

#### Seasonal Time-Based Minimum Stays

Use `seasonal-time-based-min-stays` to tailor lead-time rules to specific date
ranges so minimum-night requirements can vary by season. Each entry lists its
own rules under `time-based-min-stays`, using the same shape as
`last-minute-min-stays`. Set `exclude` to `true` to turn lead-time rules off for
the range; an excluding entry carries no `time-based-min-stays` list.

#### Gap Fill Minimum Stays

Gap-fill settings apply minimum-stay protections around short openings between
bookings. Use `gap-fill-min-stay` for the year-round setting and
`seasonal-gap-fill-min-stays` for date-range overrides; a seasonal entry wraps
its own `gap-fill-min-stay` object. `gaps` is the longest opening, in nights,
that triggers the rule, and `buffer` adds extra nights around the gap that still
count toward the rule.

---

### Min/Max Prices

Use `min-max-prices` to define nightly and monthly price floors and ceilings for
a listing.

Minimum prices are applied in this order:

1. `seasonal-prices`
2. `seasonal-day-of-week-min-prices`
3. `day-of-week-min-prices`
4. `min-price`

All minimum prices are honored by any discounting rules configured for the
listing. Prices are in the listing's currency.

A `GET` returns every rule family (empty lists when unconfigured):

```json
{
  "data": {
    "type": "min-max-price-customizations",
    "id": "123",
    "attributes": {
      "min-price": 95,
      "max-price": null,
      "monthly-min-price": null,
      "day-of-week-min-prices": [
        {"weekday": "monday", "min-price": null},
        {"weekday": "tuesday", "min-price": null},
        {"weekday": "wednesday", "min-price": null},
        {"weekday": "thursday", "min-price": null},
        {"weekday": "friday", "min-price": 120},
        {"weekday": "saturday", "min-price": 120},
        {"weekday": "sunday", "min-price": null}
      ],
      "seasonal-day-of-week-min-prices": [],
      "seasonal-prices": [
        {"start-date": "2026-12-20", "end-date": "2027-01-05",
         "rollover": true, "min-price": 150, "max-price": null}
      ],
      "seasonal-monthly-prices": []
    }
  }
}
```

#### Annual Minimum and Maximum Prices

Use `min-price` and `max-price` to set default nightly minimum and maximum
prices across all dates, except where a day-of-week or seasonal minimum applies.
We recommend setting `min-price` to the lowest nightly price you would accept at
any time of year.

#### Annual Minimum Price by Day of Week

Use `day-of-week-min-prices` to set year-round nightly minimum prices for
specific weekdays. These values take precedence over `min-price`. Any weekday
left blank uses the `min-price` value.

#### Seasonal Minimum Prices by Day of Week

Use `seasonal-day-of-week-min-prices` to set weekday-specific nightly minimum
prices for a defined date range. These values take precedence over
`day-of-week-min-prices`, but are superseded by `seasonal-prices`.

#### Seasonal Minimum and Maximum Prices

Use `seasonal-prices` to set nightly minimum and maximum prices for specific
date ranges, such as holidays or events. We do not recommend limiting our
recommended prices by setting a maximum price.

---

### Time-Based Adjustments

The `time-based-adjustments` customization supports two operating modes:
dynamic time-based adjustments and manual time-based adjustments.

A `GET` returns both the dynamic settings and any manual rules:

```json
{
  "data": {
    "type": "time-based-adjustment-customizations",
    "id": "123",
    "attributes": {
      "dynamic-time-based-adjustments": {
        "enabled": true,
        "tier": "revenue",
        "median-lead-time": 21
      },
      "time-based-adjustments": [
        {"days": 7, "percentage": -10, "direction": "within"},
        {"days": 270, "percentage": 15, "direction": "after"}
      ],
      "seasonal-time-based-adjustments": []
    }
  }
}
```

#### Dynamic Time-Based Adjustments

Dynamic Time-Based Adjustments is a core algorithm feature. When enabled, Beyond
updates the listing's discount and premium rules based on the latest booking
trends for the market and property type. Keeping this enabled ensures pricing
rules are optimized daily to maximize revenue.

When dynamic time-based adjustments is enabled, the user can choose one of the
available optimization tiers:

- `revenue`
- `occupancy`
- `rate`

`revenue` is the default and recommended option.

`median-lead-time` is the median number of days between booking and arrival for
listings of similar size in the listing's neighborhood, the same figure the
Beyond dashboard shows next to dynamic time-based adjustments. It describes the
market, not a listing setting, so it is returned whether or not dynamic
time-based adjustments is enabled. It is read-only: a `PATCH` that sends it is
accepted and the value is ignored. It is `null` when Beyond has no estimate for
the listing's neighborhood.

#### Manual Annual And Seasonal Adjustments

When dynamic time-based adjustments is disabled, the user can configure manual
annual and seasonal time-based adjustments.

#### Annual Time-Based Adjustments

Annual time-based adjustments help shape pricing across the full booking window.
Discounting dates that are coming up soon can help improve short-term occupancy.
Increasing prices for dates far in the future can help achieve higher ADRs and
avoid booking those dates too quickly.

We recommend always including an increase for dates more than 270 days
(9 months) in the future.

#### Seasonal Time-Based Adjustments

Seasonal time-based adjustments let users tailor discounts and premiums to
seasonal demand patterns. For example, you may discount more aggressively in
low season and less in high season.

Annual adjustments are pre-loaded for easy customization. Users can adjust the
rules as needed and save them to create a seasonal setting.

---

### Occupancy-Based Pricing

When enabled, occupancy-based pricing fine-tunes the listing's prices with
discounts and premiums based on its occupancy compared with its market. It is
separate from
the `tier` of [dynamic time-based adjustments](#dynamic-time-based-adjustments),
which steers lead-time rules only.

The payload includes:

- `enabled`: whether occupancy-based pricing is applied. The other fields take
  effect only while it is `true`; while it is `false` they are still saved and
  apply once it is turned back on.
- `tier`: the strategy for its discounts and premiums. `occupancy` favors
  deeper discounts and lighter premiums, `rate` favors lighter discounts and
  stronger premiums, and `revenue` balances both.
- `min-factor`: the largest decrease it may apply, as a fraction between
  `-1` and `0` with up to four decimal places. `-0.05` caps decreases at
  5%, `0` blocks decreases, and `null` means no cap.
- `max-factor`: the largest increase it may apply, as a fraction between `0`
  and `1` with up to four decimal places. `0.1` caps increases at 10%, `0`
  blocks increases, and `null` means no cap.

Today occupancy-based pricing adjusts a price by at most about 20% in either
direction, so a cap wider than that has no effect.
- `disabled-periods`: up to 50 non-overlapping date ranges during which
  occupancy-based pricing is skipped, such as holidays, local events, or owner
  stays. `end-date` is the last skipped date; use the same value for both dates
  to skip a single date.

```json
{
  "data": {
    "type": "occupancy-based-pricing-customizations",
    "id": "123",
    "attributes": {
      "enabled": true,
      "tier": "revenue",
      "min-factor": -0.05,
      "max-factor": 0.1,
      "disabled-periods": [
        {"start-date": "2026-12-24", "end-date": "2026-12-26"}
      ]
    }
  }
}
```

`PATCH` changes only the fields you send. `disabled-periods`, when sent,
replaces the listing's whole list: include every period you want to keep, or
send `[]` to remove them all. A request with overlapping periods is rejected
with `400` and changes nothing.

---

### Check-In and Check-Out Days

Use `checkin-checkout-days` to restrict the weekdays guests may arrive and
leave. Beyond pushes these rules to the listing's channel, so what you can set
depends on that channel. The read-only `channel-support` object reports it for
the listing's primary channel:

- `checkin-days`: the channel accepts check-in days. When `false`, every
  `PATCH` to this customization returns `422`.
- `checkout-days`: the channel accepts check-out days separately. When
  `false`, Beyond may replace the check-out days you send with the check-in
  days.
- `seasonal-checkin-days` / `seasonal-checkout-days`: the channel accepts
  date-specific days. When `seasonal-checkin-days` is `false`,
  `seasonal-checkin-checkout-days` accepts only `[]`.
- `day-selection` / `seasonal-day-selection`: `single` means each day list must
  be exactly one weekday or all seven; `multi` allows any set.
- `changeover-days-gap-fill`: whether `changeover-days-gap-fill-enabled` may be
  set to `true`.

The payload includes:

- `checkin-days` / `checkout-days`: the year-round weekdays. Send at least one
  weekday; all seven means no restriction.
- `seasonal-checkin-checkout-days`: date ranges that override the year-round
  days. `end-date` is the last date the season applies. A season lists at
  least one weekday in `checkin-days` or `checkout-days`; the other may be
  empty (`[]`) or `null` when the season restricts only one side. Set `rollover` to
  `true` to repeat the season every year on the same dates; the range must be
  shorter than one year, and its dates are then reported in the current year.
- `changeover-days-gap-fill-enabled`: whether orphan changeover gaps keep
  adjacent check-in/check-out rules active.

```json
{
  "data": {
    "type": "checkin-checkout-day-customizations",
    "id": "123",
    "attributes": {
      "changeover-days-gap-fill-enabled": true,
      "checkin-days": ["friday", "saturday"],
      "checkout-days": ["monday", "sunday"],
      "seasonal-checkin-checkout-days": [
        {
          "start-date": "2026-11-01",
          "end-date": "2026-11-03",
          "checkin-days": ["saturday"],
          "checkout-days": ["saturday"],
          "rollover": false
        },
        {
          "start-date": "2026-12-20",
          "end-date": "2026-12-27",
          "checkin-days": ["sunday"],
          "checkout-days": ["sunday"],
          "rollover": true
        }
      ],
      "channel-support": {
        "checkin-days": true,
        "checkout-days": true,
        "seasonal-checkin-days": true,
        "seasonal-checkout-days": true,
        "day-selection": "multi",
        "seasonal-day-selection": "multi",
        "changeover-days-gap-fill": true
      }
    }
  }
}
```

`PATCH` changes only the fields you send; `channel-support` is ignored.
`seasonal-checkin-checkout-days`, when sent, replaces the listing's whole list:
include every season you want to keep, or send `[]` to remove them all. Weekdays
are returned in calendar order, so a `GET` body can be sent back unchanged.

---

### Gap Price Adjustments

Use `gap-price-adjustments` to price the nights of short open gaps between two
bookings, which are hard to fill at the full rate. Like time-based adjustments,
it supports two operating modes: dynamic and manual.

A `GET` returns the dynamic setting, the listing's manual rules, whether those
are Beyond's defaults, and the default rules themselves:

```json
{
  "data": {
    "type": "gap-price-adjustment-customizations",
    "id": "123",
    "attributes": {
      "dynamic-gap-price-adjustments": {"enabled": false},
      "gap-price-adjustments": [
        {"days": 1, "percentage": -29, "excluded-days-of-week": []},
        {"days": 2, "percentage": 15, "excluded-days-of-week": ["friday", "saturday"]}
      ],
      "is-default": false,
      "default-gap-price-adjustments": [
        {"days": 1, "percentage": -30, "excluded-days-of-week": []},
        {"days": 2, "percentage": -20, "excluded-days-of-week": ["friday"]},
        {"days": 3, "percentage": -10, "excluded-days-of-week": ["thursday", "friday"]}
      ]
    }
  }
}
```

#### Dynamic Gap Price Adjustments

When `dynamic-gap-price-adjustments.enabled` is `true`, Beyond adjusts gaps of
up to 3 nights from market-specific booking trends, and the manual rules are not
applied. Minimum stay rules are still honored. The feature is only available in
some markets: elsewhere, a request to enable it is ignored and the response
keeps `enabled: false`.

#### Manual Gap Price Adjustments

While dynamic mode is disabled, each rule in `gap-price-adjustments` applies to
every night of an open gap of exactly `days` nights (`1` to `32767`):

- `percentage`: a whole-number percent applied to the night's price, where `-30`
  means -30% and `15` means +15%. It must be between `-99` and `999` and not
  `0`.
- `excluded-days-of-week`: optional. Gap nights falling on these weekdays keep
  their unadjusted price — for example, a Friday night in a two-night gap is
  often easy to book. Omit it to adjust every night of the gap.

Two rules for the same `days` must not adjust the same weekday; split the week
between them with `excluded-days-of-week` instead.

A `PATCH` with `gap-price-adjustments` replaces the whole list: rules you leave
out are removed, and an empty list removes every rule. This request switches the
listing to manual mode with two rules:

```json
{
  "data": {
    "type": "gap-price-adjustment-customizations",
    "id": "123",
    "attributes": {
      "dynamic-gap-price-adjustments": {"enabled": false},
      "gap-price-adjustments": [
        {"days": 1, "percentage": -29, "excluded-days-of-week": []},
        {"days": 2, "percentage": 15, "excluded-days-of-week": ["friday", "saturday"]}
      ]
    }
  }
}
```

#### Default Rules

`default-gap-price-adjustments` holds Beyond's default manual rules. They are
the same for every listing and cannot be changed. A listing that never saved
its own rules is priced with them: `gap-price-adjustments` returns them and
`is-default` is `true`.

`is-default` becomes `false` once the listing saves rules that differ from the
defaults. It stays `false` even after every rule is removed, so a listing with
no rules does not fall back to the defaults. To restore them, send
`default-gap-price-adjustments` as `gap-price-adjustments`: the listing then
keeps them as its own rules, which price the same, and `is-default` stays
`false`. Both read-only fields are ignored in a `PATCH`.

Manual rules cannot be changed while dynamic mode is enabled, including in the
request that enables it: such a `PATCH` is rejected with `422`. Send
`"dynamic-gap-price-adjustments": {"enabled": false}` to change the manual
rules. Rules sent unchanged, in any order, are accepted, so
the body of a `GET` can be sent back as is.

---

### Manual Overrides

Every other customization describes a *rule* Beyond applies while pricing. Use
`manual-overrides` when you want to take over specific dates instead, the same
way the Beyond web calendar's "Manual Override" panel does.

Each entry in `overrides` covers a date range and carries at most one of:

- `price`: a **fixed** nightly price. Beyond stops repricing those dates and
  publishes exactly this amount until you remove the override. A fixed price is
  accepted even when it falls below the listing's minimum price — an explicit
  override outranks the floor.
- `percentage-adjustment`: a **percentage** applied to Beyond's modeled price,
  where `10` means +10% and `-10` means -10%. The price keeps moving with the
  model; the adjustment is applied after every other pricing factor.

The two are mutually exclusive on any given date: setting one clears the other.
An entry with **neither** value clears both, returning the dates to fully
modeled pricing.

Each entry accepts:

- `start-date` / `end-date`: the range the override applies to. Use the same
  value for both to override a single date. Dates that have already passed in
  the listing's timezone are rejected.
- `days-of-week`: optional. Restricts the override to the named weekdays within
  the range — for example, only Fridays and Saturdays. Omit it to cover every
  date in the range.

`PATCH` is additive: dates you do not mention keep whatever override they
already had. To remove overrides, send an entry with no value for the dates you
want cleared.

#### Reading overrides back

`GET` returns one entry per overridden date, with `start-date` equal to
`end-date`, so a response body can be sent straight back as a request body.
Dates with no override are omitted entirely.

Narrow the window with `filter[start-date]` and `filter[end-date]`
(`YYYY-MM-DD`). The default window is today through today + 365 days in the
listing's timezone.

```text
GET /api/v1/listings/123/customizations/manual-overrides/?filter[start-date]=2026-06-01&filter[end-date]=2026-06-30
```

```json
{
  "data": {
    "type": "manual-override-customizations",
    "id": "123",
    "attributes": {
      "overrides": [
        {"start-date": "2026-06-05", "end-date": "2026-06-05", "price": 250,
         "percentage-adjustment": null},
        {"start-date": "2026-06-06", "end-date": "2026-06-06", "price": null,
         "percentage-adjustment": 10}
      ]
    }
  }
}
```

To see which dates on the pricing calendar carry an override without fetching
this resource, read `price-override-type` on the
[calendar](calendar.md) entries.

---

### Errors

All customization endpoints share the same error behavior. Writes are applied
through Beyond's pricing engine, and its rejections are normalized into
standard [JSON:API error responses](../guides/error-handling.md) with the
matching status code.

| Status | Cause |
| ------ | ----- |
| `400` | Malformed body or a value failing validation. Examples: `base-price` below `10`, missing weekdays in a day-of-week set, a backwards date range, a `rollover` range of one year or more, a one-off range ending more than 5 years from today, a minimum price above the maximum, a manual override on a past date, or a range overlapping an earlier one in the same list. `source.pointer` names the list item, and the field when one is at fault. |
| `401` | Missing or invalid bearer token. |
| `403` | Token lacks the required scope (`listings:read` for GET, `listings:write` for PATCH), or the credential has no edit access to the listing. |
| `404` | No listing with that id is visible to your application. |
| `422` | The update is semantically impossible for the listing — e.g. manual gap price adjustments while dynamic gap price adjustments are enabled, a feature the listing's managed account does not support, or check-in/check-out days the listing's channel does not accept (see `channel-support`). |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

---

---
description: >-
  Beyond API calendar endpoint: daily availability and pricing data for a listing, including suggested and posted prices.
---

## Calendar

| Endpoint | Method | Scope | Description |
|----------|--------|-------|-------------|
| `/api/v1/listings/<id>/calendar/` | GET | `reservations:read` | Get calendar data for a listing |

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Listings).

### Get Listing Calendar

Returns daily calendar data for a listing, including pricing, availability, and
pricing factors — one entry per date in the requested range.

Calendar price fields are returned in the listing's own currency — the one the
[listing resource](listings.md) reports as `currency`. It is not the owner's
billing currency, which is what [market insights](insights.md) rates are priced
in.

### Request

```
GET /api/v1/listings/123/calendar/
    ?filter[start-date]=2026-03-10
    &filter[end-date]=2026-03-11
    &sort=date
```

| Parameter | Description |
| --------- | ----------- |
| `filter[start-date]` | First date (`YYYY-MM-DD`). Defaults to today in the listing's timezone. |
| `filter[end-date]` | Last date (`YYYY-MM-DD`). Defaults to today + 365 days. Must not be before `filter[start-date]`. |
| `sort` | `date` (ascending, default) or `-date`. Any other field is a `400`. |
| `fields[calendar-entries]` | JSON:API sparse fieldset — return only the named attributes. |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **366**, maximum **731**. See below. |

#### Pagination

This endpoint's page size is much larger than the API-wide default, so a normal
date range arrives in a single response:

| Parameter | Default | Maximum |
|-----------|---------|---------|
| `page[size]` | 366 | 731 |

The default 365-day window therefore returns **one page**. Do not paginate it
into 25-entry pages — that would turn one request into fifteen and, across a
portfolio, run you into [rate limits](../guides/rate-limiting.md).

!!! warning "The filter names are `filter[start-date]` and `filter[end-date]`"
    This endpoint reads those two parameters by exact name. Any other
    `filter[…]` key — `filter[start]` and `filter[end]` included — is **ignored
    without an error**, and you get the default today → today + 365 window back
    with a `200`. If the dates in the response are not the ones you asked for,
    check the parameter names.

### Response

```json
{
  "data": [
    {
      "type": "calendar-entries",
      "id": "2026-03-10",
      "attributes": {
        "date": "2026-03-10",
        "availability": "available",
        "price": 101,
        "price-posted": 100,
        "effective-min-price": 80,
        "effective-max-price": 250,
        "price-override-type": null,
        "factors": [
          {
            "key": "seasonality",
            "order": 1,
            "ratio": 0.12,
            "amount": 11
          },
          {
            "key": "event",
            "order": 2,
            "ratio": 0.05,
            "amount": 5,
            "reason": "City Marathon",
            "event-impact": "high"
          },
          {
            "key": "feature_occupancy_premium",
            "order": 3,
            "ratio": 0.03,
            "amount": 3,
            "metadata": {
              "listing-occupancy": 45.45,
              "market-occupancy": 27.32
            }
          }
        ]
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

An entry's `id` is its date string, so `type` + `id` is unique per listing
request.

#### Availability Values

| Value | Description |
|-------|-------------|
| `available` | The date is available to be booked |
| `booked` | The date has a corresponding reservation |
| `blocked` | The date is blocked off for a hold |
| `unavailable` | The date cannot be booked for an unknown reason |

#### Price Fields

All price fields are integers in **whole currency units**: a `price` of `189`
on a `USD` listing is $189 per night. Values are never expressed in cents —
do not divide by 100.

| Field | Description |
|-------|-------------|
| `price` | Beyond Pricing suggested price (shown in the BP dashboard) |
| `price-posted` | Last price posted to the channel/PMS (`null` when never posted) |
| `effective-min-price` | Effective minimum price floor; the modeled price will not go below this |
| `effective-max-price` | Effective maximum price ceiling; the modeled price will not exceed this (`null` when unset) |
| `price-override-type` | Which manual override, if any, produced `price` (`null` when the price is purely modeled) |

!!! tip
    Use `price-posted` when you need the price that will actually be quoted if a booking is made. Fall back to `price` if `price-posted` is `null`.

#### Manual Override State

`price-override-type` tells you whether someone has taken over pricing for a date:

| Value | Meaning |
|-------|---------|
| `null` | No manual override. `price` is Beyond's modeled price and moves as the model updates. |
| `fixed` | A fixed price is pinned to the date. `price` is that exact amount and Beyond will not reprice the date. |
| `percentage` | A percentage adjustment is applied on top of the modeled price. `price` still moves as the model updates. |

The override *values* — and the ability to set or clear them — live on the
[manual overrides customization](listing-customizations.md#manual-overrides).

#### Pricing Factors

Each calendar entry includes a `factors` array describing the individual components that shaped the modeled price (e.g. seasonality, day-of-week, events).

| Field | Description |
|-------|-------------|
| `key` | Factor identifier (e.g. `seasonality`, `dow`, `event`) |
| `order` | Display/application order of the factor |
| `ratio` | Factor's contribution as a ratio (e.g. `-0.19` means -19%) |
| `amount` | Factor's contribution to the modeled price, in the same whole currency units as `price` (e.g. `-19` lowered the price by $19 on a USD listing) |
| `reason` | Human-readable reason for the factor (e.g. event name); omitted for factors that carry none, and `null` for an event with no name |
| `event-impact` | Event impact level (`low`, `medium`, `high`); only present for event factors |
| `metadata` | Extra context specific to the factor's type; only present for the factors listed below |

!!! note
    `amount` values do not necessarily sum to (`price` − base price). Minimum/maximum price limits and final rounding are applied between factors and are not attributed to any single factor, and factors whose effect rounds to less than 1 report `0`.

##### Factor Metadata

A few factor types carry extra context in a `metadata` object explaining what drove them. Each key belongs to one factor type and is absent for all the others, so read `metadata` against the factor's `key` rather than assuming a fixed shape.

| Key | Present on | Description |
|-----|-----------|-------------|
| `listing-occupancy` | `feature_occupancy_premium`, `feature_occupancy_discount` | The listing's occupancy over the factor's date range, as a percentage (e.g. `45.45` means 45.45%). `null` when Beyond has no reading for the listing |
| `market-occupancy` | `feature_occupancy_premium`, `feature_occupancy_discount` | The listing's market's occupancy over the same range, as a percentage. `null` when Beyond has no reading for the market |
| `bedroom` | `pacing_premium`, `pacing_discount` | Bedroom count the pacing factor was looked up under |
| `bad-review-date` | `reputation_discount` | Date of the review that triggered the discount (`YYYY-MM-DD`), once the triggering review is known |

Together these let you explain a factor rather than just report it — for example, a `feature_occupancy_premium` with `listing-occupancy: 45.45` and `market-occupancy: 27.32` raised the price because the listing is filling faster than its market.

### Use cases

1. **Price display.** Show `price-posted` (falling back to `price`) beside your
   PMS rates to confirm what Beyond is publishing for each date.
2. **Price explanation.** Render the `factors` array — with `reason`,
   `event-impact`, and `metadata` — to explain *why* a date is priced the way
   it is, instead of just reporting the number.
3. **Override audit.** Scan a range for `price-override-type != null` to find
   dates where a person has taken over pricing, then read the values from the
   [manual overrides customization](listing-customizations.md#manual-overrides).
4. **Floor/ceiling checks.** Compare `price` with `effective-min-price` /
   `effective-max-price` to see which dates are pinned to a limit rather than
   floating with demand.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | Listing not yet clustered (see below); invalid dates (`end` before `start`, malformed `YYYY-MM-DD`); invalid `sort` field; insufficient cluster data. |
| `401` | Missing or invalid bearer token. |
| `403` | Token does not have the `reservations:read` scope. |
| `404` | No listing with that id is visible to your application. |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

#### Not Yet Clustered (400)

A listing that has no pricing cluster assigned cannot serve its calendar: the
endpoint returns a `400` with a "not yet clustered" message. Most listings are
clustered as part of the initial import, so this is uncommon — it happens when
a listing was created without a cluster, or lost its cluster later. The
[listing resource](listings.md#pricing-readiness-in-active-market) surfaces
this state through `in-active-market`, which is `false` while the listing has
no cluster (and `true` only when the calendar is available). The
[`listing.in_active_market_changed`](../webhooks/in-active-market-changed.md)
webhook tells you when a priced listing's `in-active-market` changes in
either direction — no polling needed.

---

## Compsets

- `GET /api/v1/compsets/` — list the comp sets you can see (paginated summary).
- `GET /api/v1/compsets/<id>/` — get one comp set, fully enriched.
- Scope: `compsets:read`

!!! tip "Try it out"
    Explore schemas and make live requests in the [Swagger UI](/api/v1/docs/#/Compsets).

### What is a compset?

A **compset** (Competitive Set) is a group of comparable nearby listings used
to benchmark a property's pricing and performance. Beyond surfaces the
competitive set for a listing — its members are comparable nearby properties
drawn from the channels Beyond tracks — together with the headline KPIs (posted
rate, occupancy, minimum stay) operators use to position their property in the
local market.

### Compset kinds

Every comp set carries a `kind` discriminator so you can tell the types apart —
some fields are only populated for some kinds:

| `kind`      | Description |
| ----------- | ----------- |
| `connected` | Anchored to a Beyond listing (whether that listing's comp set was user-curated or system-generated). |
| `custom`    | Built against a typed-in custom listing, with no Beyond listing. **Beta.** |

!!! warning "Beta"
    The `custom` kind is a **beta** feature. Its response shape and behavior may
    change without notice, and it is not yet covered by backward-compatibility
    guarantees.

For a `custom` comp set (no Beyond listing) the `source.min-stay`,
`source.base-price`, `source.health-score`, and `source.market` fields are
`null`, the whole `performance.main-listing-metrics` block is `null`, and
`source.currency` falls back to the user's billing currency.

### List comp sets

`GET /api/v1/compsets/`

Returns a paginated list of **summary** entries — cheap fields only (no metric
or member enrichment) — enough to choose which comp set to fetch in detail. Each
entry has its `kind`, `title`, `listing-id`, total `member-count`, and
timestamps.

- **Pagination:** `page[number]` and `page[size]` (max 100).
- **Filtering:** `filter[owner]=<user_id>` (owning user),
  `filter[listing]=<id>` (Beyond listing id),
  `filter[kind]=custom|connected`.
- **Sorting:** `sort=created-at` (default `-created-at`, newest first) or
  `sort=title`; prefix with `-` for descending.

```
GET /api/v1/compsets/?filter[kind]=connected&sort=title&page[size]=50
```

!!! note "`filter[owner]`"
    `filter[owner]` narrows the list to a single user. It is only meaningful for
    full-access partner tokens; a user-scoped token already sees just its own
    user, so the filter has no additional effect.

#### Response

```json
{
  "data": [
    {
      "type": "compsets",
      "id": "55",
      "attributes": {
        "listing-id": 123,
        "kind": "connected",
        "title": "Sunny 2BR near the beach",
        "member-count": 8,
        "created-at": "2026-06-10T14:22:00Z",
        "updated-at": "2026-08-01T09:00:00Z"
      },
      "relationships": {
        "owner": {"data": {"type": "users", "id": "456"}},
        "listing": {"data": {"type": "listings", "id": "123"}}
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

#### Compound Documents (Sideloading)

Include related resources in a single request using the `?include=` parameter:

- `owner` -- the user who owns the comp set
- `listing` -- the Beyond listing the comp set is anchored to (`null` for
  `custom` comp sets)

```
GET /api/v1/compsets/?include=owner,listing
```

This avoids separate API calls to fetch the owner or listing. See the
[JSON:API guide](../guides/jsonapi.md#compound-documents-sideloading) for details
on how compound documents work.

### Get a comp set

`GET /api/v1/compsets/<id>/`

Returns one comp set by its id, with the base listing anchor (`source`), its
headline metrics over the next 30 and 90 days, the curated `members[]` enriched
with per-member attributes and metrics, and a `performance` comparison against
the aggregated compset benchmark — enough to render a full side-by-side
comparison in a single round trip.

Returns **404** when no comp set with that id is visible to your application.

!!! note "Stricter rate limit"
    This is a heavy endpoint, so it carries a stricter limit of **30 requests/minute** per
    user/application. Exceeding it returns **429**; cache responses and avoid tight polling loops.

#### Performance comparison — `start_date` / `end_date`

The response always includes a `performance` block: the listing's metrics
compared to the **aggregated compset benchmark** for the requested date range
(posted rate, occupancy, minimum stay, booked rate, adjusted occupancy).
Control the window with the `start_date` and `end_date` query parameters
(defaults: today → today + 90 days, max 180-day range). This is the heaviest
part of the query — clients backing real-time UIs should cache results.

#### Response

Abbreviated to one member; real responses carry every member and the full
metric key set described under [Notable Fields](#notable-fields):

```json
{
  "data": {
    "type": "compsets",
    "id": "55",
    "attributes": {
      "listing-id": 123,
      "kind": "connected",
      "title": "Sunny 2BR near the beach",
      "matched-airbnb-id": "987654321",
      "created-at": "2026-06-10T14:22:00Z",
      "updated-at": "2026-08-01T09:00:00Z",
      "source": {
        "bedrooms": 2,
        "bathrooms": 1.5,
        "latitude": 37.7749,
        "longitude": -122.4194,
        "address": "123 Main St",
        "city": "San Francisco",
        "state": "CA",
        "country": "US",
        "market": "San Francisco",
        "currency": "USD",
        "base-price": 189.0,
        "health-score": 82,
        "min-stay": 2,
        "metrics": {
          "thirty-day-price": 210.5,
          "ninety-day-price": 195.0,
          "thirty-day-adr": 205.0,
          "ninety-day-adr": 188.0,
          "thirty-day-booked": 71.4,
          "ninety-day-booked": 63.0,
          "thirty-day-availability": 28.6,
          "ninety-day-availability": 37.0,
          "thirty-day-min-stays": 2,
          "ninety-day-min-stays": 2,
          "rating": 4.9,
          "reviews-count": 213,
          "average-service-fee": 31.2
        }
      },
      "members": [
        {
          "channel": "airbnb",
          "channel-listing-id": "13358898",
          "title": "Bright loft downtown",
          "url": "https://www.airbnb.com/rooms/13358898",
          "image": "https://images.example.com/loft.jpg",
          "bedrooms": 2.0,
          "bathrooms": 1.0,
          "room-type": "entire_home",
          "latitude": 37.7712,
          "longitude": -122.4103,
          "distance-km": 1.2,
          "rating": 4.8,
          "reviews-count": 145,
          "health-score": 76.0,
          "metrics": {
            "thirty-day-adr": 198.0,
            "ninety-day-adr": 182.0,
            "thirty-day-booked": 66.7,
            "ninety-day-booked": 58.9,
            "thirty-day-availability": 33.3,
            "ninety-day-availability": 41.1,
            "thirty-day-min-stays": 2,
            "ninety-day-min-stays": 2,
            "thirty-day-adj-occupancy": 70.0,
            "ninety-day-adj-occupancy": 61.0,
            "thirty-day-booked-rate": 198.0,
            "ninety-day-booked-rate": 182.0,
            "base-price": 175.0,
            "currency": "USD",
            "rating": 4.8,
            "reviews-count": 145,
            "average-service-fee": 28.4
          }
        }
      ],
      "performance": {
        "start-date": "2026-08-10",
        "end-date": "2026-11-08",
        "currency": "USD",
        "main-listing-metrics": {
          "average-posted-rate": 212.0,
          "occupancy": 68.5,
          "average-min-stay": 2.0,
          "average-booked-rate": 204.0,
          "adj-occupancy": 71.2
        },
        "aggregated-metrics": {
          "average-posted-rate": 197.0,
          "occupancy": 61.3,
          "average-min-stay": 2.1,
          "average-booked-rate": 189.0,
          "adj-occupancy": 64.8,
          "number-of-nearby-listings": 8
        }
      }
    }
  }
}
```

#### Notable Fields

- `kind` — `custom` or `connected` (see [Compset kinds](#compset-kinds)).
- `listing-id` — the Beyond listing the comp set is anchored to (`null` for
  `custom` comp sets).
- `title` — the comp set's name.
- `matched-airbnb-id` — external id Beyond matched to the base listing, when
  available.
- `source` — the base listing's attributes (bedrooms, bathrooms, location,
  `base-price`, `min-stay`, `health-score`) and its headline `metrics`.
- `source.metrics` — base-listing KPIs over the next 30 and 90 days:
  `thirty-day-price` / `ninety-day-price` (posted rate), `*-adr` (booked rate),
  `*-booked` (occupancy), `*-availability`, `*-min-stays`, plus `rating` and
  `reviews-count`.
- `members[]` — the curated comparables, each with attributes (`channel`,
  `channel-listing-id`, `title`, `url`, `image`, `bedrooms`, `bathrooms`,
  `room-type`, `distance-km`, `rating`, `reviews-count`, `health-score`) and a
  `metrics` block.
- `members[].channel-listing-id` — the comparable's **channel-native** listing
  id (the id in `members[].url`), not a Beyond listing id.
- `members[].distance-km` — distance from the base listing, in kilometers.
- `members[].metrics` — per-member KPIs over the next 30 and 90 days: `*-adr`
  and `*-booked-rate` (rates), `*-booked` (occupancy), `*-availability`,
  `*-adj-occupancy`, `*-min-stays`, plus `base-price`.
- `performance.main-listing-metrics` — the base listing over the requested
  range: `average-posted-rate`, `occupancy`, `average-min-stay`,
  `average-booked-rate`, `adj-occupancy`. `null` for `custom` comp sets.
- `performance.aggregated-metrics` — the same figures aggregated across the
  comp set, plus `number-of-nearby-listings` (members contributing to the
  aggregate).

Metrics a channel does not provide are returned as `null`.

### Use cases

1. **Browse and pick.** List comp sets with `GET /compsets/`, optionally
   `filter[kind]` or `filter[listing]`, then fetch the chosen one in detail.
2. **Side-by-side comparison table.** Combine `source` and `members[]` to
   render a comparison grid — bedrooms, bathrooms, `room-type`, `rating`,
   `reviews-count`, `distance-km`, `health-score`, and a thumbnail (`image`)
   linking out (`url`) — and highlight where the base listing trails the
   cluster.
3. **Pricing pacing.** Compare the base listing's `source.metrics`
   `thirty-day-price` / `ninety-day-price` against members'
   `thirty-day-adr` / `ninety-day-adr` to spot where the operator is
   materially above or below the local market.
4. **Occupancy & availability benchmarking.** Track `thirty-day-booked` /
   `ninety-day-booked` (occupancy) and the `*-availability` metrics across
   `source` and `members[]` to tell apart soft pricing from soft demand.
5. **Minimum-stay strategy.** Compare the base listing's `min-stay` and
   `*-min-stays` against members to validate length-of-stay rules for the
   market.
6. **Reputation & review competitiveness.** Compare the base listing's `rating`
   and `reviews-count` (`source.metrics`) against members' `rating` /
   `reviews-count` to spot where weak social proof — not price — is suppressing
   conversion, and prioritize review generation where the listing trails the
   cluster.
7. **Listing-quality benchmarking.** Rank the base listing's
   `source.health-score` against members' `health-score` to surface quality
   headroom (content, photos, listing completeness) to address before adjusting
   price.
8. **Performance scorecard.** Use the `performance` block to benchmark the
   listing against the aggregated compset over a chosen date range
   (`average-posted-rate`, `occupancy`, `average-booked-rate`, `adj-occupancy`,
   `average-min-stay`) and roll the result up to a portfolio scorecard.
9. **Seasonality & demand pacing.** Sweep `start_date` / `end_date` across
   consecutive windows (e.g. month by month) and chart
   `performance.aggregated-metrics` over time to map the local demand curve and
   see where the listing's pacing diverges from the market by season. Cache
   results — this is the heaviest part of the query.

### Errors

| Status | Cause |
| ------ | ----- |
| `400`  | Malformed `start_date`/`end_date`, or range > 180 days. |
| `401`  | Missing or invalid bearer token. |
| `403`  | Token does not have the `compsets:read` scope, or the credential lacks privileges on the comp set's listing. |
| `404`  | No comp set with that id is visible to the application. |
| `429`  | Rate limit exceeded — the detail endpoint is capped at 30 requests/minute per application. |

---

## Market Insights

`GET /api/v1/listings/<id>/market-insights/`

- Scope: `insights:read`
- Description: compare one listing's daily performance against the benchmark
  for its neighborhood or its whole market.

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Insights).

### What you get

One entry per stay date, each carrying **both sides** of the comparison:

- `listing` — this listing's own performance, from its reservations and calendar.
- `benchmark` — the comparable-property benchmark for the cohort this listing is
  compared against, built from publicly available data.

This is the same pair of series the **Market Data** tab charts inside the Beyond
app.

The comparison is anchored to a listing you already have access to, and the
cohort is always derived from that listing — you choose its *grain* with
`filter[compare-to]`, never the cohort itself.

### The benchmark

The `benchmark` side is not a whole-city average. It is the listing's own
cohort, narrowed to the listing's **bedroom count** and to entire-home
rentals — a comparable-grade benchmark rather than a coarse area-wide figure.

| Derived from | Behavior |
| ------------ | -------- |
| Neighborhood or market | The listing's own, per `filter[compare-to]`. A listing assigned to neither is a [`422`](#errors). |
| Bedrooms | The listing's own bedroom count, echoed as `meta.cohort-bedrooms`. Studios (`0`) and one-bedrooms (`1`) share one cohort, `["0", "1"]`, because listing sites often list a studio as a one-bedroom. A listing with no bedroom count set compares against all bedroom counts. |
| Currency | The listing owner's billing currency, echoed as `meta.currency`. |

For a **multi-unit parent** listing, the `listing` side aggregates the parent's
sub-units — the parent holds no reservations itself, so it would otherwise read
as 0% occupied.

### Comparison scope

`filter[compare-to]` selects how wide the benchmark cohort is drawn. Both
values are derived from the listing — neither lets you name an area you have no
listing in.

| Value | Cohort |
| ----- | ------ |
| `cluster` *(default)* | The listing's **neighborhood** — the Beyond-defined area it sits in. This is what the in-app Market Data tab charts. |
| `market` | The listing's **whole market**, typically a city or region. Coarser, but populated where a neighborhood is too thin to measure. |

Reach for `market` when the default returns
`meta.benchmark-data-available: false`, or when a listing is assigned to a
market but not to a neighborhood — that case is a `422` under `cluster` and
answers normally under `market`.

Because the two produce different numbers for the same date, `compare-to` is
part of an entry's [id](#entry-ids) and is echoed as `meta.compare-to`.

### Request

```
GET /api/v1/listings/123/market-insights/
    ?filter[start-date]=2026-01-01
    &filter[end-date]=2026-06-30
    &page[size]=366
```

| Parameter | Description |
| --------- | ----------- |
| `filter[start-date]` | First stay date (`YYYY-MM-DD`). Defaults to 1 January of the current year. |
| `filter[end-date]` | Last stay date (`YYYY-MM-DD`). Defaults to 31 December of the current year. The range must not exceed **731 days**. |
| `filter[compare-to]` | Which cohort to benchmark against: `cluster` (default) or `market`. See [Comparison scope](#comparison-scope). Any other value is a `400`. |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **366**, maximum **731**. |

One entry is returned for **every day in the requested window**, whether or not
either side has data for it, so the response length is a function of the request
alone. The default window is the **current calendar year**, which is one page at
the default page size in every year, leap years included — omit both date
filters and a single request gives you the whole year, history and forward book
together.

!!! note "Stricter rate limit"
    This is a heavy endpoint: each call runs a full analytics scan. It carries a
    stricter limit of **30 requests/minute**, on top of the application-level
    limit that applies to every endpoint. Both counters are scoped the same way:
    per Beyond user if you authenticate with a personal access token, per
    application if you are a partner integration. Exceeding it returns **429** —
    cache responses and avoid tight polling loops. See
    [Rate Limiting](../guides/rate-limiting.md#what-shares-a-bucket).

### Response

```json
{
  "data": [
    {
      "type": "market-insights",
      "id": "123:cluster:2026-03-01",
      "attributes": {
        "date": "2026-03-01",
        "adj-occupancy":        {"listing": 71.4,  "benchmark": 63.0},
        "occupancy":            {"listing": 68.2,  "benchmark": 59.0},
        "average-booked-rate":  {"listing": 210.5, "benchmark": 188.0},
        "average-posted-rate":  {"listing": 225.0, "benchmark": 191.0}
      }
    },
    {
      "type": "market-insights",
      "id": "123:cluster:2026-08-20",
      "attributes": {
        "date": "2026-08-20",
        "adj-occupancy":        {"listing": 0.0,   "benchmark": 58.0},
        "occupancy":            {"listing": 0.0,   "benchmark": 55.0},
        "average-booked-rate":  {"listing": null,  "benchmark": 175.0},
        "average-posted-rate":  {"listing": 240.0, "benchmark": 179.0}
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 2},
    "currency": "GBP",
    "benchmark-data-available": true,
    "compare-to": "cluster",
    "cohort-bedrooms": ["2"],
    "start-date": "2026-01-01",
    "end-date": "2026-12-31"
  }
}
```

#### Entry ids

An entry's `id` is `{listing-id}:{compare-to}:{stay-date}` — for example
`123:cluster:2026-03-01`.

The stay date alone would not identify it. The `benchmark` half of a row depends
on which listing you asked about *and* on which cohort you compared it to, so
the same date carries different numbers from one listing to the next and from
`cluster` to `market`. Folding both in keeps ids unique the way
[JSON:API](../guides/jsonapi.md) expects, which matters if your client caches or
de-duplicates resources by `type` and `id`.

Ids are stable: the same request always returns the same id for the same date.

#### Metrics

Every metric is an object with the same two keys, `listing` and `benchmark`.

| Field | Description |
| ----- | ----------- |
| `date` | The stay date this entry describes. |
| `adj-occupancy` | Adjusted occupancy, as a percentage: booked nights over **available** nights. Nights that were blocked or otherwise unavailable do not count against it. |
| `occupancy` | Occupancy over **all** nights, including nights that were never available to book. Lower than `adj-occupancy` whenever there is blocked inventory. |
| `average-booked-rate` | Average nightly rate actually **booked**, in `meta.currency`. `null` on a date with no bookings. |
| `average-posted-rate` | Average nightly rate **on offer**, booked or not, in `meta.currency`. Use it where `average-booked-rate` is `null`. |

These names match the ones [comp sets](compsets.md) uses for the same
quantities, so the two endpoints can be read with one vocabulary.

Rates are in **major currency units** — `210.5` means 210.50 in
`meta.currency`, not 210 cents. The [calendar](calendar.md) endpoint uses the
same major units, but as whole-number integers in the listing's own currency.

#### `null` never means zero

This is the most important thing to get right when charting the response.

- `0.0` is a **real measurement**: the listing was available and nothing booked.
- `null` means the metric **could not be computed** for that date — no
  availability, no bookings, or no benchmark coverage.

The second entry in the example above is the case worth reading twice. On
`2026-08-20` the listing was available and unbooked, so `adj-occupancy.listing`
is a genuine `0.0`, `average-booked-rate.listing` is `null` (no bookings,
therefore no booked rate), and `average-posted-rate.listing` is `240.0` — it was
on offer at 240 and nobody took it.

Plot `null` as a gap, not as zero. Averaging `null` as `0` will understate rates
on every unbooked date. If you want the app's behavior — a continuous line —
fall back explicitly: `average-booked-rate` → `average-posted-rate` for prices,
`adj-occupancy` → `occupancy` for occupancy. Both sides of each pair are in the
response precisely so that the choice is yours rather than ours.

#### Document `meta`

| Field | Description |
| ----- | ----------- |
| `currency` | ISO currency code every rate in the response is expressed in. **Derived**, see below. |
| `benchmark-data-available` | `false` when there is no benchmark coverage at all for the cohort in the requested window. The `benchmark` side is `null` throughout; the `listing` side is still fully populated. Widening with `filter[compare-to]=market` often recovers coverage. |
| `compare-to` | The cohort grain actually applied, `cluster` or `market`. Echoed so you can tell a defaulted request from an explicit one. See [Comparison scope](#comparison-scope). |
| `cohort-bedrooms` | The bedroom counts the benchmark was narrowed to, as a list — the listing's own, e.g. `["2"]`, except that a studio or one-bedroom listing gets `["0", "1"]`. `null` when the listing has no bedroom count set, meaning **every** bedroom count was included. It is a set of exact counts, not a maximum. |
| `start-date`, `end-date` | The stay-date window actually applied, after defaulting. Read these rather than assuming, especially when you omit the date filters. |
| `pagination` | Standard [pagination](../guides/jsonapi.md#pagination) metadata. |

!!! warning "`currency` is derived, not requested"
    There is no currency parameter. Every rate is returned in the currency from
    the listing owner's **billing configuration**, and `meta.currency` names it
    on every response.

    That is not the listing's own `currency` — the one [listings](listings.md)
    reports and [calendar](calendar.md) prices are quoted in. The two are set
    independently and can differ, so convert before comparing a rate from this
    endpoint with a price from another one.

    Read `meta.currency` on every response rather than caching it per listing:
    it is the only signal you get, and it changes when the billing
    configuration does.

### Use cases

1. **Occupancy pacing.** Chart `adj-occupancy.listing` against
   `adj-occupancy.benchmark` over the forward window to see where the listing is
   filling faster or slower than its cohort — the earliest signal that
   pricing is off for a date range.
2. **Rate positioning.** Compare `average-booked-rate.listing` with
   `average-booked-rate.benchmark` to find dates where the listing books below what
   comparable properties achieve.
3. **Distinguishing soft pricing from soft demand.** Occupancy at or above the
   benchmark while the booked rate trails it means the listing is
   underpriced; both below means demand is the constraint.
4. **Ask vs. achieved.** The gap between `average-posted-rate` and
   `average-booked-rate` on the same side shows how much of the asking price
   guests actually pay.
5. **Blocked-inventory audit.** A wide, persistent gap between `adj-occupancy`
   and `occupancy` on the listing side quantifies revenue lost to blocked
   nights rather than to weak demand.
6. **Portfolio scan.** Sweep the endpoint across a portfolio and rank listings
   by their gap to their own cohort — each is benchmarked against its own
   surroundings, so the comparison holds across locations. Mind the 30/minute
   limit and spread the sweep.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | Malformed `filter[start-date]` / `filter[end-date]`, end before start, or a range longer than 731 days. |
| `401` | Missing or invalid bearer token. |
| `403` | Token does not have the `insights:read` scope. |
| `404` | No listing with that id is visible to your application — it does not exist, is not owned by your application, was deleted, has no active channel listing, or your credential has no access to it. |
| `422` | The listing cannot be matched to a cohort, or the benchmark query failed. See below. |
| `429` | Rate limit exceeded — this endpoint is capped at 30 requests/minute, counted per Beyond user for personal access tokens and per application for partner integrations. See [Rate Limiting](../guides/rate-limiting.md#what-shares-a-bucket). |

The two `422` cases are distinguished by the error's `code`:

| `code` | Meaning |
| ------ | ------- |
| `listing_not_analysable` | The listing is not assigned to a cohort, so there is nothing to compare it against. A listing with a market but no neighborhood hits this only under the default `filter[compare-to]=cluster` — retry with `market` and it answers. A listing with no market at all is not retryable; the assignment has to change first. |
| `market_insights_unavailable` | The benchmark query failed. Transient — retry with backoff. |

```json
{
  "errors": [
    {
      "status": "422",
      "detail": "Listing 123 is not assigned to a market, so it has no neighborhood to compare against.",
      "source": {"pointer": "/data"},
      "code": "listing_not_analysable"
    }
  ]
}
```

An empty benchmark is **not** an error: it is a `200` with
`meta.benchmark-data-available: false`. Your listing's own series is still there.

---

## Recommendations

`GET /api/v1/listings/<id>/recommendations/`

- Scope: `listings:read`
- Description: List base-price recommendations for a listing

!!! tip "Try it out"
    Explore schemas and make live requests in the [Swagger UI](/api/v1/docs/#/Listings).

### List Recommendations

Returns recommendations for a listing owned by the authenticated application,
newest first.

Recommendations are generated periodically using the newest data available for
the listing and its market. They are designed to suggest pricing updates as
market conditions change over time. The endpoint currently returns **base price**
recommendations only (`category` is always `base_price`).

By default, Beyond accepts base price changes automatically. Partners can use
this endpoint to review the latest recommendation state and understand whether a
base price recommendation is pending, approved, rejected, expired, or already
applied automatically. When no recommendations exist for the listing, the
response is an empty collection.

#### Request

```
GET /api/v1/listings/123/recommendations/
```

| Parameter | Description |
| --------- | ----------- |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **25**, maximum **100**. Entries are ordered newest first. |

#### Response

```json
{
  "data": [
    {
      "type": "recommendations",
      "id": "9001",
      "attributes": {
        "listing-id": 123,
        "listing-title": "Sunny 2BR near the beach",
        "status": "suggestion_completed",
        "approved-at": null,
        "rejected-at": null,
        "expired-at": null,
        "suggested-base-price": 175,
        "suggested-min-price": 120,
        "suggested-seasonal-min-price-pct": -10.5,
        "initial-base-price": 150,
        "initial-min-price": 100,
        "recommendations": "Raise the base price before the next weekend.",
        "category": "base_price",
        "created-at": "2026-03-25T12:00:00Z"
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

#### Fields

All prices are in the listing's `currency` (the one the
[listing resource](listings.md) reports), in the same units as the listing's
`base-price`.

| Field | Description |
| ----- | ----------- |
| `listing-id`, `listing-title` | The listing the recommendation belongs to. |
| `status` | Lifecycle status. See [Status values](#status-values). |
| `approved-at`, `rejected-at`, `expired-at` | When the recommendation reached that terminal state; `null` otherwise. |
| `suggested-base-price` | Suggested nightly base price derived from the latest listing and market data. |
| `suggested-min-price` | Suggested minimum nightly price, when the recommendation carries one; `null` otherwise. |
| `suggested-seasonal-min-price-pct` | Suggested seasonal minimum-price adjustment, as a percentage (`-10.5` means -10.5%); `null` when not suggested. |
| `initial-base-price`, `initial-min-price` | The base and minimum price recorded when the recommendation was created — the baseline the suggestion moves from. |
| `recommendations` | Free-text explanation of the recommendation, when one was written. |
| `category` | Recommendation category. Currently always `base_price`. |
| `created-at` | When the recommendation record was created. |

#### Status values

| Value | Meaning |
| ----- | ------- |
| `suggested` | Drafted, not yet delivered to the end user. |
| `suggestion_completed` | Delivered and awaiting a decision — the actionable state. |
| `approved` | The end user accepted the suggestion. |
| `rejected` | The end user declined the suggestion. |
| `expired` | No action was taken and the suggestion expired automatically. |
| `canceled` | Withdrawn before a decision. |
| `outdated` | Superseded by newer data before a decision. |

### Use cases

1. **Surface pending suggestions.** Show recommendations with
   `status: suggestion_completed` to the property manager, with
   `suggested-base-price` next to `initial-base-price` so the size of the
   change is visible.
2. **Track outcomes.** Read the terminal timestamps (`approved-at`,
   `rejected-at`, `expired-at`) to report how recommendations were handled.
3. **Detect applied changes.** After an approval, confirm the listing's
   `base-price` on the [listings endpoint](listings.md) and watch the
   [`listing.base_price_changed`](../webhooks/base-price-changed.md) webhook
   rather than polling.

### Errors

| Status | Cause |
| ------ | ----- |
| `401` | Missing or invalid bearer token. |
| `403` | Token does not have the `listings:read` scope. |
| `404` | No listing with that id is visible to your application. |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

---

## Users

Users are the property managers your application manages. Listings and
[accounts](managed-accounts.md) always belong to a user.

| Endpoint              | Method   | Scope        | Token Tier     | Description                     |
| --------------------- | -------- | ------------ | -------------- | ------------------------------- |
| `/api/v1/users/`      | GET      | `user:read`  | Both           | List all users (paginated)      |
| `/api/v1/users/`      | POST     | `user:write` | App-level only | Create a new user               |
| `/api/v1/users/<id>/` | GET      | `user:read`  | Both           | Retrieve a user                 |
| `/api/v1/users/<id>/credentials/` | GET | `user:read` | Both | List credentials for a user |
| `/api/v1/users/<id>/` | DELETE   | `user:write` | App-level only | Delete a user                   |

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Users).

### User Status

Every user resource includes a read-only `status` attribute describing its lifecycle state. It is derived from enabled-listing state and updates automatically as listings are enabled or disabled.

| Value      | Meaning                                              |
| ---------- | ---------------------------------------------------- |
| `new`      | Created, but no listings enabled yet.                |
| `active`   | Has at least one enabled listing.                    |
| `inactive` | Was active before; no listings currently enabled.    |

### List Users

Returns a paginated list of all users owned by the authenticated application.

#### Request

```
GET /api/v1/users/?filter[email]=john@example.com
```

| Parameter | Description |
| --------- | ----------- |
| `filter[email]` | Exact, case-insensitive match on the user's email. Email is unique, so this returns at most one user — use it to recover a user's `id` from an email. A non-owned email yields an empty list. |
| `sort` | `created-at` or `-created-at` (default: newest first). |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **25**, maximum **100**. |

!!! note "User-scoped tokens"
    When using a user-scoped token, this endpoint returns only the user the token is bound to.

#### Response

```json
{
  "data": [
    {
      "type": "users",
      "id": "456",
      "attributes": {
        "first-name": "John",
        "last-name": "Doe",
        "email": "john@example.com",
        "locale": "en-GB",
        "created-at": "2026-05-20T18:03:11Z",
        "status": "active"
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

| Field | Description |
| ----- | ----------- |
| `first-name`, `last-name` | The user's name. |
| `email` | The user's email. Unique across Beyond. |
| `locale` | The user's locale as a [BCP 47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language tag. |
| `created-at` | When the user was created. |
| `status` | Lifecycle state — see [User Status](#user-status). |

### Retrieve a User

`GET /api/v1/users/<id>/` returns a single user, with the same attributes as
the list entries above. `404` when the user is not owned by your application.

### Create User

Create a new user managed by your application. Users created via this endpoint are managed by the OAuth2 application and cannot log in directly (password is randomly generated).

!!! warning "App-level token required"
    This endpoint requires an app-level token. Requests with a user-scoped token will receive `403 Forbidden`.

#### Request

```json
{
  "data": {
    "type": "users",
    "attributes": {
      "first-name": "John",
      "last-name": "Doe",
      "email": "john@example.com",
      "locale": "en-GB"
    }
  }
}
```

| Field | Description |
| ----- | ----------- |
| `first-name`, `last-name` | Required. |
| `email` | Required. Must be unique — an existing email is a `409 Conflict`. |
| `locale` | Optional, defaults to `en`. A supported [BCP 47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) tag: `en`, `en-GB`, `en-AU`, `en-CA`, `ja`, `fr`, `pt`, `de`, `es`, or `it`. Used in messages that explain the reason for price changes. |

Returns `201 Created` with the created user resource, including its `id` —
store it, every other user-scoped call needs it.

### List User Credentials

Returns the login credentials that are visible for the requested user.

- App-level tokens can list all credentials for the user.
- User-scoped tokens default to the bound credential. Admin credentials can list all credentials for that user; non-admin credentials only see themselves.
- Each credential object has its own `id`. Use that `credential_id` when requesting a user-scoped token that must enforce one credential's visibility and grants.

#### Response

```json
{
  "data": [
    {
      "type": "credentials",
      "id": "789",
      "attributes": {
        "created-at": "2026-05-20T18:03:11Z",
        "last-login": "2026-08-01T07:40:02Z",
        "credential-type": "email",
        "global-permissions": "Admin",
        "email": "john@example.com"
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

| Field | Description |
| ----- | ----------- |
| `created-at` | When the credential was created. |
| `last-login` | Last login with this credential; `null` if never used. |
| `credential-type` | How the credential authenticates (e.g. `email`). |
| `global-permissions` | The credential's permission level on the account. |
| `email` | The credential's login email; `null` for credential types without one. |

### Delete User

Soft-delete a user managed by your application. This anonymizes the user's email, disables all enabled listings, removes managed accounts, and marks the user as deleted.

Returns `204 No Content` on success.

!!! warning "App-level token required"
    This endpoint requires an app-level token. Requests with a user-scoped token will receive `403 Forbidden`.

### Use cases

1. **Onboarding.** Create the user first, then [add an account](managed-accounts.md#add-account)
   for them — the account sync imports their listings.
2. **Id recovery.** Look up a user's `id` with `filter[email]` instead of
   storing a separate mapping.
3. **Offboarding.** Delete the user when the property manager leaves your
   platform; their listings stop syncing and their accounts are removed.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | Malformed request body or missing required attribute on create. |
| `401` | Missing or invalid bearer token. |
| `403` | Token lacks the required scope, or a user-scoped token calling an app-level-only operation (create, delete). |
| `404` | No user with that id is owned by your application. |
| `409` | Create with an email that already exists. |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

---

## Accounts

Accounts represent channel connections (e.g., Airbnb, Hostaway, or other PMS accounts) linked to a user.

| Endpoint | Method | Scope | Description |
| -------- | ------ | ----- | ----------- |
| `/api/v1/users/<user_id>/accounts/` | GET | `user:read` | List accounts for a user |
| `/api/v1/users/<user_id>/accounts/` | POST | `user:write` | Add an account for a user |
| `/api/v1/users/<user_id>/accounts/<account_id>/` | GET | `user:read` | Get one account for a user |
| `/api/v1/users/<user_id>/accounts/<account_id>/refresh/` | POST | `user:write` | Queue a full refresh for an account |
| `/api/v1/users/<user_id>/accounts/<account_id>/` | DELETE | `user:write` | Delete an account |

!!! tip "Try it out"
    Explore parameters, schemas, supported channels with their required credentials, and live requests in the [Swagger UI](/api/v1/docs/#/Accounts).

### List Accounts

Returns a paginated list of accounts (channel connections) for the specified user.

Use this endpoint to monitor the background import that starts after an account is created. The response includes `sync-status`, which can be used to detect whether the account is still `queued`, `in_progress`, or `completed`.

For the end-to-end workflow, see the [partner onboarding flow](../getting-started/partners.md#end-to-end-onboarding-flow).

#### Request

```
GET /api/v1/users/456/accounts/
```

| Parameter | Description |
| --------- | ----------- |
| `page[number]`, `page[size]` | Standard [pagination](../guides/jsonapi.md#pagination). Default page size **25**, maximum **100**. Newest accounts first. |

#### Response

```json
{
  "data": [
    {
      "type": "accounts",
      "id": "123",
      "attributes": {
        "user-id": 456,
        "channel": "hostaway",
        "channel-id": "hw-account-789",
        "channel-display-id": null,
        "label": "Hostaway",
        "email": "user@example.com",
        "valid": true,
        "nb-enabled-listings": 12,
        "nb-listings": 15,
        "is-pms": true,
        "sync-status": {
          "state": "completed",
          "last-successful-sync-at": "2026-08-09T04:12:44Z"
        }
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

| Field | Description |
| ----- | ----------- |
| `user-id` | The user who owns this account. |
| `channel` | Channel identifier (e.g. `airbnb`, `hostaway`, `guesty`). |
| `channel-id` | The account's identifier on the channel side. |
| `channel-display-id` | Human-readable channel-side id, when the channel provides one; `null` otherwise. |
| `label` | Human-readable channel label. |
| `email` | Email associated with this account on the channel; `null` when the channel does not expose one. |
| `valid` | Whether the stored credentials are currently valid on the channel. |
| `nb-listings`, `nb-enabled-listings` | Total and enabled listing counts imported from this account. |
| `is-pms` | Whether this channel is a property management system. |
| `sync-status.state` | `queued`, `in_progress`, `completed`, or `unknown`. |
| `sync-status.last-successful-sync-at` | Timestamp of the last successful listing sync; `null` if never synced. |

### Get Account

`GET /api/v1/users/<user_id>/accounts/<account_id>/` returns one account, with
the same attributes as the list entries above. `404` when the user or account
is not visible to your token.

### Add Account

Add a channel connection (account) for a user. Each channel requires specific credentials (API key, client ID/secret, JWT, etc.). The Swagger UI documents the complete list of 50+ supported channels and the credentials each one requires.

User-scoped tokens may call this endpoint, but the bound credential must have global `edit` or `admin` permissions.

#### Request

```json
{
  "data": {
    "type": "accounts",
    "attributes": {
      "channel": "hostaway",
      "credentials": {
        "client_id": "12345",
        "client_secret": "abcdef123456"
      }
    }
  }
}
```

| Field | Description |
| ----- | ----------- |
| `channel` | Required. Channel identifier (e.g. `hostify`, `hostaway`, `guesty`). An unsupported value is a `400`. |
| `credentials` | Required. Channel-specific credential object — the field set differs per channel and is validated against that channel's schema. See the [Swagger UI](/api/v1/docs/#/Accounts) for every channel's required fields. Credential keys are snake_case, as shown. |

The endpoint also accepts one optional query parameter:

| Parameter | Description |
| --------- | ----------- |
| `sync-priority` | Optional. `high` (the default) or `normal`. See [Sync priority](#sync-priority) below. Any other value is a `400`. |

Returns `201 Created` with the account resource (same shape as the list
entries), with `sync-status.state` still `queued`.

If channel validation or authentication fails, the API returns `422 Unprocessable Entity` with a `meta.channel` field identifying which channel failed and a `meta.code` field with the error type.

Send one request per account and wait for it to answer before sending another. Several `POST /accounts/` calls for the same user and channel in flight at once all resolve to the same account and are applied one at a time; a request that waits too long for its turn — because another create or a credentials refresh for the same account is still running — is rejected with `409 Conflict` and `code: "concurrent_modification"`. It changed nothing and is safe to retry, but the wait is avoidable by not sending requests concurrently. See [409 -- Conflict](../guides/error-handling.md#409-conflict).

Creating an account starts a background sync that imports reservation history and creates listings for the connected account. Rather than polling `sync-status`, subscribe to the [`account.created`](../webhooks/account-created.md) webhook, which fires once that sync finishes and reports how many listings were created.

##### Sync priority

The `sync-priority` query parameter tells Beyond how soon the account's import should start.

| Value | Effect | Use it when |
| ----- | ------ | ----------- |
| `high` | Beyond starts the import as soon as it can. | Someone is waiting on the result, for example an account connected during an interactive signup. |
| `normal` | The import can take longer to start when Beyond is busy. | You add many accounts at once and none of them is urgent. |

The parameter is optional. A request that leaves it out is treated as `high`.

Priority affects only how soon the import starts. Either value imports the same data and fires the same [`account.created`](../webhooks/account-created.md) webhook.

```
POST /api/v1/users/456/accounts/?sync-priority=normal
```

The request body is unchanged:

```json
{
  "data": {
    "type": "accounts",
    "attributes": {
      "channel": "hostify",
      "credentials": {
        "api_key": "your-api-key-here"
      }
    }
  }
}
```

### Refresh Account

Queue a full listings and reservations refresh for an existing account.

User-scoped tokens may call this endpoint, but the bound credential must have global `edit` or `admin` permissions.

Use this endpoint when you want to manually trigger another import after the account has already been connected, for example after fixing channel-side data issues or when you want to force a fresh sync without reconnecting the account.

This refresh is prioritized ahead of normal queued work and replaces an existing pending account refresh that has not started yet.

If a sync for the account is already running, the API returns `409 Conflict` instead of queuing another one. Wait for the in-progress sync to finish (or for the [`account.refreshed`](../webhooks/account-refreshed.md) webhook, if registered) and retry shortly.

#### Request

```
POST /api/v1/users/456/accounts/123/refresh/?recent-sync-threshold-minutes=0
```

| Parameter | Description |
| --------- | ----------- |
| `recent-sync-threshold-minutes` | Optional. Skip listings synced within the last N minutes. Defaults to `60`; `0` disables the skip and refreshes all listings. A non-integer value is a `400`. |

The snake_case spelling `recent_sync_threshold_minutes` is deprecated. The endpoint still accepts it, so existing integrations keep working, but new integrations must use `recent-sync-threshold-minutes`. When a request carries both, the dasherized name wins.

No request body. The endpoint returns `202 Accepted` once the refresh job is
queued:

```json
{
  "meta": {
    "status": "accepted",
    "message": "Refresh queued",
    "recent-sync-threshold-minutes": 60
  },
  "links": {
    "related": "$BASE_URL/api/v1/users/456/accounts/123/"
  }
}
```

The refresh runs asynchronously — poll the account's `sync-status` via
`links.related`, or subscribe to the
[`account.refreshed`](../webhooks/account-refreshed.md) webhook. Reservations
are refreshed by separate background jobs and can still be in flight when that
event fires.

### Delete Account

Remove an account (channel connection) for the specified user.

User-scoped tokens may call this endpoint, but the bound credential must have global `edit` or `admin` permissions.

Returns `204 No Content` on success.

Deletes of the same account are applied one at a time. A delete that cannot take its turn before the server's wait bound returns `409 Conflict` with `code: "concurrent_modification"` having changed nothing.

### Deleting and Re-creating an Account

Deleting an account is a soft delete: the account and its listings are retained, not erased. Re-creating the account with the **same channel and credentials** revives the same account, and its previously synced listings are resurrected on the next sync rather than being recreated from scratch.

Two conditions must hold for recovery to work:

- The credentials must still be valid on the channel. A revoked or invalid key returns `422` with a `Invalid Credentials` error instead of reviving the account.
- The channel must return the same account and listing IDs as before.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | Unsupported `channel`, credentials failing the channel's schema, or an invalid `recent-sync-threshold-minutes`. |
| `401` | Missing or invalid bearer token. |
| `403` | Token lacks the required scope, or the bound credential lacks global `edit`/`admin` permissions for a write. |
| `404` | User or account not found, or not visible to your token. |
| `409` | Refresh requested while a sync is already running for the account (`code: "sync_in_progress"`), or the request timed out waiting for a concurrent operation on the same account (`code: "concurrent_modification"`). Both are transient — nothing about the request needs to change before retrying. |
| `422` | Channel validation/authentication failed (`meta.channel` names the channel, `meta.code` the error type). |
| `429` | Rate limit exceeded — refresh is additionally capped at 100 requests/minute. See [Rate Limiting](../guides/rate-limiting.md). |
| `502` | The channel's own API failed while validating the connection. Transient — retry with backoff. |

---

## Webhook Configuration

| Endpoint                          | Method | Scope            | Token Tier        | Description                              |
| --------------------------------- | ------ | ---------------- | ----------------- | ---------------------------------------- |
| `/api/v1/webhook-configuration/`  | GET    | `webhooks:read`  | Both              | Read your webhook destination and events |
| `/api/v1/webhook-configuration/`  | PATCH  | `webhooks:write` | Application-level | Change your destination or events        |

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Webhooks).

This endpoint is how you **configure webhook delivery for your own application**:
where deliveries go, and which event types you want. It is a singleton — your
application has exactly one webhook configuration, and there is no id in the URL
because the subject is derived from your token.

For what each event contains, see [Webhooks](../webhooks/index.md). To read back
what was already delivered, see [Webhook Events](webhook-events.md).

!!! note "`webhooks:write` is application-level"
    This configuration applies to every user your application serves, so it can
    only be changed with an **application-level** token. A user-scoped token can
    read the configuration but cannot be issued the `webhooks:write` scope at
    all. Ask Beyond to enable `webhooks:write` for your application if it is not
    in your allowed scopes.

### Read Your Configuration

```
GET /api/v1/webhook-configuration/
```

```json
{
  "data": {
    "type": "webhook-configurations",
    "id": "your-client-id",
    "attributes": {
      "webhook-url": "https://example.com/beyond/webhooks",
      "subscribed-events": ["*"],
      "available-events": [
        "account.created",
        "account.refreshed",
        "listing.base_price_changed",
        "listing.created",
        "listing.in_active_market_changed",
        "listing.refreshed"
      ]
    }
  }
}
```

| Attribute           | Description                                                                   |
| ------------------- | ----------------------------------------------------------------------------- |
| `id`                | Your application's client id.                                                 |
| `webhook-url`       | Where deliveries are sent. Empty means delivery is off.                       |
| `subscribed-events` | The event types you receive. `["*"]` means every type — see below.            |
| `available-events`  | Every event type you may subscribe to (read-only).                            |

Your signing secret is **never** returned here, or anywhere else in the API. It
is shown once when it is issued or rotated.

### Choosing Your Events

`subscribed-events` is returned exactly as stored, and `["*"]` is meaningful in
its own right:

| Value | Meaning |
| ----- | ------- |
| `["*"]` | Every event type, **including ones Beyond adds in future**. This is the default. |
| `["listing.created", "account.refreshed"]` | Exactly these types. New event types are **not** delivered until you add them here. |
| `[]` | No events at all. Delivery is effectively off while your URL stays configured. |

Pick `["*"]` if you want everything and would rather opt out later; pick an
explicit list if your receiver rejects or mishandles payloads it does not
recognize. `["*"]` combined with concrete types is stored as `["*"]` — the
wildcard always wins, so you cannot accidentally narrow yourself while it is set.

```
PATCH /api/v1/webhook-configuration/
Content-Type: application/vnd.api+json
```

```json
{
  "data": {
    "type": "webhook-configurations",
    "id": "your-client-id",
    "attributes": {
      "subscribed-events": ["listing.created", "listing.base_price_changed"]
    }
  }
}
```

Every attribute you send **replaces** the stored value — `subscribed-events` is
not merged, so send the complete list you want rather than the additions. Omit an
attribute to leave it untouched. An event type that is not in `available-events`
returns `400`, naming both the value and the valid list.

Every update must carry `data.id`, your own client id — the same value `GET`
returns. JSON:API requires it on an update, so a request without it returns
`400`, and one naming a different application returns `409`.

### Changing Your Destination URL

```json
{
  "data": {
    "type": "webhook-configurations",
    "id": "your-client-id",
    "attributes": {
      "webhook-url": "https://example.com/beyond/webhooks-v2"
    }
  }
}
```

The new URL takes effect **immediately**. Beyond does not probe it first, so make
sure it is live and verifying signatures before you switch — and note that
deliveries already in flight, including retries of earlier events, may still land
on the previous URL for a short period. Run both endpoints in parallel over a
cutover if you cannot tolerate that.

The URL must:

- use **`https`**;
- point at a **publicly reachable host** — `localhost`, loopback, link-local
  (`169.254.0.0/16`), and private literals (`10.0.0.0/8`, `172.16.0.0/12`,
  `192.168.0.0/16`) are rejected with `400`.

Send `""` to switch delivery off without losing your event selection; `null` is
accepted and read back as `""`. Turning delivery back on is the same request with
a URL again.

!!! warning "Changing this redirects your signed event stream"
    A token holding `webhooks:write` can point your event stream at any public
    HTTPS endpoint. Treat it with the same care as any other credential that can
    move data, and grant it only to systems that need to configure webhooks.

### Rotating the Signing Secret

Not yet available through the API. Contact Beyond to rotate it — see
[Rotation](../webhooks/index.md) in the webhooks guide for what a rotation
involves today.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | An event type not in `available-events`, a missing `data.id`, or a `webhook-url` that is not public HTTPS. |
| `401` | Missing or invalid bearer token. |
| `403` | Token lacks `webhooks:read` (GET) or `webhooks:write` (PATCH — application-level tokens only). |
| `409` | `data.id` names a different application than the one your token authenticates. |
| `429` | Rate limit exceeded — this endpoint is capped at 30 requests/minute. See [Rate Limiting](../guides/rate-limiting.md). |

---

## Webhook Events

| Endpoint                          | Method | Scope           | Token Tier | Description                                   |
| --------------------------------- | ------ | --------------- | ---------- | --------------------------------------------- |
| `/api/v1/webhook-events/`         | GET    | `webhooks:read` | Both       | List the webhook events delivered to you      |
| `/api/v1/webhook-events/<msg_id>/`| GET    | `webhooks:read` | Both       | Retrieve one delivered event, including body  |

!!! tip "Try it out"
    Explore parameters, schemas, and live requests in the [Swagger UI](/api/v1/docs/#/Webhooks).

These endpoints let you **read back the webhook events Beyond has already sent to
your endpoint** — useful for reconciling events you may have missed while your
receiver was down. They report what was delivered and how it landed (delivery
status, HTTP response code, and any error), not the raw HTTP retries. For the
event catalog and payload shapes, see [Webhooks](../webhooks/index.md).

The `msg_id` is the same stable id Beyond sends in the `webhook-id` header of
each delivery (the value you dedupe on).

!!! note "Token scope determines visibility"
    An **application-level** token sees every event delivered to the app's webhook
    endpoint. A **user-scoped** token sees only the events belonging to the user it
    is bound to. Visibility is derived from your token — there is no query parameter
    to widen it.

### List Webhook Events

Returns a paginated list of the events delivered to your application, newest
first. Supports [pagination](../guides/jsonapi.md#pagination).

- **Filtering:**
    - `filter[event-type]=<type>` — only events of one type (e.g. `listing.refreshed`).
    - `filter[status]=<status>` — only events in one delivery status: `pending`,
      `delivered`, `failed`, or `exhausted`. Any other value returns `400`.
    - `filter[created-after]=<timestamp>` — only events created at or after an ISO
      8601 timestamp (e.g. `2026-07-13T19:00:00Z`).
    - `filter[created-before]=<timestamp>` — only events created at or before an
      ISO 8601 timestamp. Combine with `created-after` to bound a window.
- **Including the body:** `include-body=true` inlines each event's full body
  (as a nested JSON object) into every list item, so you can reconcile **and**
  reprocess a range of events without a follow-up request per event. It is off by
  default to keep the list light.

```
GET /api/v1/webhook-events/?filter[status]=failed&filter[created-after]=2026-07-13T19:00:00Z&include-body=true&page[size]=50
```

By default each resource carries the delivery metadata and outcome, but **not**
the body — either pass `include-body=true` above, or fetch a single event.

#### Response

```json
{
  "data": [
    {
      "type": "webhook-events",
      "id": "msg_2h9pXbFqR7Jk",
      "attributes": {
        "event-type": "listing.refreshed",
        "status": "delivered",
        "content-type": "application/vnd.api+json",
        "attempts": 1,
        "last-status-code": 200,
        "last-error": null,
        "created-at": "2026-08-01T10:15:00Z",
        "delivered-at": "2026-08-01T10:15:02Z",
        "failed-at": null
      }
    }
  ],
  "meta": {
    "pagination": {"page": 1, "pages": 1, "count": 1}
  }
}
```

| Attribute          | Description                                                        |
| ------------------ | ------------------------------------------------------------------ |
| `event-type`       | The event that was delivered.                                      |
| `status`           | `pending`, `delivered`, `failed`, or `exhausted`.                  |
| `content-type`     | Content-Type of the delivery.                                      |
| `attempts`         | Number of delivery attempts made.                                  |
| `last-status-code` | HTTP status of the last delivery attempt (if any).                 |
| `last-error`       | Error from the last failed attempt (if any).                       |
| `created-at`       | When Beyond accepted the event.                                    |
| `delivered-at`     | When the event was delivered with a 2xx (if it was).               |
| `failed-at`        | When the event reached a terminal failure (if it did).             |

#### Delivery status

| Value       | Meaning                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| `pending`   | Accepted; delivery is still in flight or being retried.                 |
| `delivered` | Your endpoint returned a 2xx.                                           |
| `failed`    | Your endpoint returned a non-retryable response; the event was dropped. |
| `exhausted` | Retryable failures exhausted all attempts; the event was not delivered. |

### Retrieve a Webhook Event

Returns a single delivered event, including the `body` that was sent — the
JSON:API document your endpoint received, returned as a **nested JSON object** you
can traverse directly (not an escaped string). It also adds `url`, the endpoint
this event was delivered to (omitted from the list, where it is identical on
every row). The event is returned only when it falls within your token's scope; a
`msg_id` belonging to another partner (or, for a user-scoped token, another user)
returns `404 Not Found`.

```
GET /api/v1/webhook-events/msg_01H.../
```

#### Response

The detail resource carries every list attribute plus `url` and `body`:

```json
{
  "data": {
    "type": "webhook-events",
    "id": "msg_2h9pXbFqR7Jk",
    "attributes": {
      "event-type": "listing.refreshed",
      "status": "delivered",
      "content-type": "application/vnd.api+json",
      "attempts": 1,
      "last-status-code": 200,
      "last-error": null,
      "created-at": "2026-08-01T10:15:00Z",
      "delivered-at": "2026-08-01T10:15:02Z",
      "failed-at": null,
      "url": "https://partner.example.com/webhooks/beyond",
      "body": {
        "data": {
          "type": "listing-refreshed-events",
          "id": "msg_2h9pXbFqR7Jk",
          "attributes": {"...": "the document your endpoint received"}
        }
      }
    }
  }
}
```

### Use cases

1. **Gap recovery.** After receiver downtime, list with
   `filter[created-after]` set to the outage start and `include-body=true`,
   then feed each `body` through the same handler your live endpoint uses.
2. **Delivery monitoring.** Poll `filter[status]=exhausted` (and `failed`)
   to detect events that never reached you, with `last-status-code` /
   `last-error` explaining why.
3. **Dedupe audit.** Cross-check the `webhook-id` values you processed against
   the ids listed here to confirm nothing was silently dropped.

### Errors

| Status | Cause |
| ------ | ----- |
| `400` | Unknown `filter[status]` value, or a malformed timestamp filter. |
| `401` | Missing or invalid bearer token. |
| `403` | Token does not have the `webhooks:read` scope. |
| `404` | `msg_id` not found within your token's scope. |
| `429` | Rate limit exceeded. See [Rate Limiting](../guides/rate-limiting.md). |

---

---
description: >-
  Beyond API webhooks: signed, real-time event notifications pushed to your backend — verification, retries, and the full event catalog.
---

## Webhooks

Webhooks let Beyond Pricing push events to your backend the moment they happen,
so you do not have to poll the API. When something you care about occurs — a
reservations refresh finishes, a listing's base price changes — Beyond sends a
signed HTTP `POST` to an endpoint you register.

Every delivery is a [JSON:API](../guides/jsonapi.md) document signed with
[Standard Webhooks](https://www.standardwebhooks.com/) (HMAC-SHA256), so you can
verify it really came from Beyond and was not tampered with in transit.

!!! info "Availability"
    Webhooks are available to **Partners** and to **personal users**.

    - **Partners** configure them per OAuth2 application; events fire for the
      application that owns a resource's user. See [Getting Set Up](#getting-set-up).
    - **Personal users** configure one endpoint per Beyond account from the
      dashboard; events fire for everything on that account. See
      [For personal users](#for-personal-users).

### Event Catalog

Each event is documented independently, including exactly when it fires and the
full shape of its payload.

| Event | Event type | Resource type | Fires when |
| ----- | ---------- | ------------- | ---------- |
| [Account Created](account-created.md) | `account.created` | `account-created-events` | The background listing sync that follows adding a managed account finishes (success or failure). |
| [Account Refreshed](account-refreshed.md) | `account.refreshed` | `account-refreshed-events` | The background listing sync started by refreshing a managed account finishes (success or failure). |
| [Listing Created](listing-created.md) | `listing.created` | `listing-created-events` | A new listing owned by one of your users is fully set up (its initial base price is computed). |
| [Listing In Active Market Changed](in-active-market-changed.md) | `listing.in_active_market_changed` | `listing-in-active-market-changed-events` | A listing's `in-active-market` attribute changes — it leaves or re-enters the state where its calendar is guaranteed to work. |
| [Listing Refresh](listing-refreshed.md) | `listing.refreshed` | `listing-refreshed-events` | A partner-triggered listing refresh chain finishes (success or failure). |
| [Listing Base Price Changed](base-price-changed.md) | `listing.base_price_changed` | `listing-base-price-changed-events` | A listing's effective base price or currency changes on any Beyond surface; `enabled` marks live vs. speculative changes. |
| [Webhook Ping](webhook-ping.md) | `webhook.ping` | `webhook-ping-events` | A personal user saves a webhook URL or sends a test ping from the dashboard. Verification only; acknowledge and ignore. |

### Getting Set Up

Webhook delivery is configured **per OAuth2 application**. To start receiving
events, provide Beyond with:

1. **An endpoint URL** — an HTTPS URL on your backend that accepts `POST`
   requests.
2. Beyond returns a **signing secret** in the form `whsec_<base64-key>`. Store
   it securely; you need it to verify every delivery.

Contact Beyond during onboarding to register your endpoint and receive your
secret, the same way IP allowlists are provisioned for partner applications. If
an application has no endpoint or secret configured, matching events are simply
not delivered — nothing else changes.

Once you are set up you can manage the endpoint yourself, without contacting us:
[Webhook Configuration](../endpoints/webhook-configuration.md) reads and updates
your destination URL and your event selection, behind the `webhooks:write` scope.

### For Personal Users

If you automate your own Beyond account with a [personal access
token](../getting-started/personal-users.md), you configure webhooks yourself
from the dashboard's **Personal Access Tokens** page. Only an administrator of
the account can manage them, and the same Beyond Pro requirement as for
personal access tokens applies.

1. **Generate a signing secret.** Beyond shows the `whsec_…` value once. Add
   it to your receiver first: every delivery, including the verification ping,
   is signed with it.
2. **Save your endpoint URL.** It must be a public `https` URL. Beyond sends a
   signed [`webhook.ping`](webhook-ping.md) to it and saves the URL only when
   your endpoint answers with a `2xx`. Redirects are not followed, and a
   failed ping leaves your previous settings untouched; the dashboard shows
   why it failed.
3. **Choose your events.** You receive every event type by default, including
   ones added later. Untick "All events" to pin an explicit list.

After that you can **send a test ping** at any time, **rotate** the secret (the
new value is shown once and applies to the next delivery; update your receiver
first), or **remove** the endpoint, which stops delivery immediately.

Events about your account fire the same way as for partners: a new listing, a
base price change, and so on. You can read back what Beyond delivered with a
PAT through the [Webhook Events](../endpoints/webhook-events.md) endpoints;
personal access tokens carry the `webhooks:read` scope.

### Choosing Your Events

By default an application receives **every** event type, including ones Beyond
adds in future — that is what `subscribed-events: ["*"]` means, and it is what
you get unless you change it. Nothing in the catalog above is opt-in.

If your receiver only handles some of these, narrow the list to exactly the types
you process:

```
PATCH /api/v1/webhook-configuration/
```

```json
{
  "data": {
    "type": "webhook-configurations",
    "id": "your-client-id",
    "attributes": {
      "subscribed-events": ["listing.created", "listing.base_price_changed"]
    }
  }
}
```

The trade-off is deliberate: pinning an explicit list means a new event type
Beyond ships is **not** delivered until you add it, so you are never surprised by
a payload you did not plan for — but you also have to come back for anything new.
See [Webhook Configuration](../endpoints/webhook-configuration.md#choosing-your-events).

Unsubscribing applies to every event, including those that report on a job you
started yourself: turn off `listing.refreshed` and a refresh you requested through
the API completes without notifying you.

### Changing Your Destination URL

The same endpoint moves your deliveries to a new URL. For partners there is **no
verification step** — the new URL is used from the moment the write succeeds, so
confirm it is live and verifying signatures first. (Personal users saving a URL
from the dashboard get a verification ping instead; see
[For personal users](#for-personal-users).) Deliveries already in flight, including
retries of earlier events, may still land on the old URL for a short period; keep
both endpoints up across a cutover if you cannot tolerate a gap.

Setting `webhook-url` to `""` stops delivery while keeping your event selection
intact, which is the clean way to pause webhooks.

!!! note "Rotation"
    Treat the signing secret like a credential. If it is ever exposed, ask Beyond to
    rotate it. Rotation is currently a **hard cutover**: deliveries are signed with the
    old secret until the change lands, and with the new one immediately after, with no
    overlap window. Coordinate a rotation window with us so you can swap the secret on
    your side at the same moment. (The `webhook-signature` header is a list precisely so
    that an overlap period can be added later without changing your verification code.)

### Delivery Model

```mermaid
sequenceDiagram
    participant BP as Beyond Pricing
    participant N as Notifications Service
    participant P as Your Endpoint

    BP->>N: Event occurs, enqueue delivery
    N->>N: Build deterministic body, sign (HMAC-SHA256)
    N->>P: POST signed JSON:API document
    alt Any 2xx
        P-->>N: 200 OK
        Note over N: Delivered — done
    else 5xx / 408 / 429 / timeout / network error
        P-->>N: error
        N->>P: Retry with exponential backoff (up to 5 attempts)
    else Other 4xx (incl. 410)
        P-->>N: error
        Note over N: Not retried
    end
```

- **Transport**: HTTPS `POST` to your registered URL.
- **Content-Type**: `application/vnd.api+json` (a JSON:API document).
- **Body**: byte-stable JSON with deterministic key ordering. The signature is
  computed over the **raw bytes** — verify and parse those exact bytes, do not
  re-serialize before verifying.

#### Request Headers

Every delivery carries the [Standard
Webhooks](https://www.standardwebhooks.com/) headers:

| Header | Description |
| ------ | ----------- |
| `webhook-id` | Stable event identifier (`msg_<base32 ULID>`). **Re-used across delivery retries** — use it as your idempotency key. Matches `data.id` in the body. |
| `webhook-timestamp` | Unix time (seconds) when this attempt was sent. **Re-stamped on every retry.** Reject deliveries whose timestamp is more than ~5 minutes from your clock to defend against replay. |
| `webhook-signature` | Space-separated list of `v<version>,<base64-signature>` entries (currently just `v1,…`). See [verification](#verifying-signatures). |

### Verifying Signatures

Always verify the signature **before** trusting or parsing the body.

The signed content is the string `{webhook-id}.{webhook-timestamp}.{raw-body}`,
and the signature is
`base64(HMAC-SHA256(key, signed_content))`, where the key is your secret with
the `whsec_` prefix stripped and base64-decoded to 32 raw bytes.

!!! tip "Use an official library"
    The simplest, safest path is a [Standard Webhooks
    library](https://www.standardwebhooks.com/#resources) for your language — it
    handles the timestamp tolerance and constant-time comparison for you. Pass
    it your `whsec_…` secret, the request headers, and the raw body.

If you verify manually, mirror this recipe (Python):

```python
import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict[str, str], raw_body: bytes) -> bool:
    """Return True if the delivery is authentic and fresh."""
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]

    # Replay protection: reject deliveries that are too old (or from the future).
    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode("ascii")

    # webhook-signature is a space-separated list of "v<version>,<signature>".
    for entry in headers["webhook-signature"].split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

!!! warning "Always compare in constant time"
    Use a constant-time comparison (`hmac.compare_digest` above) so a naive
    string comparison cannot leak the signature byte-by-byte through timing.

### Responding to Deliveries

- **Acknowledge fast.** Return any **2xx** as soon as you have safely received
  the event (for example, after persisting it to a queue). Do the real work
  asynchronously — slow handlers risk a timeout, which Beyond treats as a
  failure and retries.
- **Return `410 Gone`** if the endpoint is permanently retired. Beyond stops
  retrying that delivery; future iterations may auto-disable endpoints that
  respond `410`.

#### Retries

Deliveries that do not succeed are retried automatically:

| Response | Behavior |
| -------- | -------- |
| Any `2xx` | Delivered. No retry. |
| `5xx`, `408`, `429`, timeout, or network error | Retried with exponential backoff. |
| Other `4xx` (including `410`) | Not retried — treated as a permanent rejection. |

Beyond makes up to **5 attempts** (the initial delivery plus 4 retries) with
exponential backoff (roughly 1s, 5s, 25s, 125s, capped at 300s), honoring a
`Retry-After` header when you send one. After the final attempt, the delivery is
dropped.

If your receiver was down and you need to recover events you missed, you can read
back what Beyond delivered — including the delivery status and each event's body —
via the [Webhook Events](../endpoints/webhook-events.md) endpoints (`webhooks:read`
scope).

#### Idempotency

Because retries re-use the same `webhook-id`, the **same event may arrive more
than once**. Make your handler idempotent: dedupe on `webhook-id` (equivalently,
`data.id` in the body) and treat a repeat as a no-op. A given `webhook-id` always
represents the same event with the same body.

### Payload Envelope

Every event shares the same JSON:API envelope. The `meta` block is identical
across events; `data` is event-specific and documented on each event's page.

```json
{
  "meta": {
    "type": "listing.refreshed",
    "sent-at": "2026-07-03T12:34:56Z"
  },
  "data": {
    "type": "listing-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": { "...": "event-specific" },
    "relationships": { "...": "event-specific" }
  }
}
```

| `meta` field | Description |
| ------------ | ----------- |
| `type` | The event type (e.g. `listing.refreshed`). Your routing key, and where the version lives if one is ever introduced — see [Versioning](../guides/versioning.md#webhook-events). |
| `sent-at` | RFC 3339 UTC timestamp when the envelope was assembled. **Not** when the thing happened. |

The event's identifier appears exactly twice: in the
`webhook-id` header, and as `data.id`. There is no schema-version field:
if an event ever needs a breaking change, it ships under a **new event type**
rather than a bumped envelope — see [Versioning](../guides/versioning.md#webhook-events).

!!! tip "Two kinds of `type`"
    An event carries two `type` fields and they do different jobs:

    - **`meta.type`** (`listing.refreshed`) is the *event* type — **route on this**, and
      **acknowledge (`2xx`) any type you do not recognize**. New event types (including a
      `.v2` of one you handle) are delivered as soon as they exist — see
      [Versioning](../guides/versioning.md#webhook-events).
    - **`data.type`** (`listing-refreshed-events`) is the JSON:API *resource* type —
      deserialize on this.

    See the [JSON:API guide](../guides/jsonapi.md#types-in-webhook-payloads) for why the
    event type lives under `meta`.

!!! warning "`data.id` is the event id, not a domain id"
    The document you receive describes *the event*, so `data.id` is the event's ULID —
    the same value as the `webhook-id` header, and what you dedupe on. It is *not* the id
    of the listing or account the event is about. The domain object is always reached
    through `relationships` — its id is in `relationships.<name>.data.id`, and
    `relationships.<name>.links.related` is a URL you can `GET` for its current state.

!!! note "`sent-at` is not the occurrence time"
    `meta.sent-at` stamps the envelope. When the thing actually happened lives in the
    event's own attributes — `completed-at`, `changed-at`, or `created-at`, depending on
    the event. They can differ by minutes, and on a retry `sent-at` stays fixed while the
    `webhook-timestamp` header is re-stamped.

!!! note "Field names are dasherized"
    Like the rest of the API, event field names use dashes (`base-price`,
    `completed-at`, `channel-listing-id`). See the [JSON:API
    guide](../guides/jsonapi.md#key-conventions).

### OpenAPI / Machine-Readable Schema

The webhook payloads are published in the OpenAPI 3.1 schema as top-level
`webhooks`, alongside the request/response endpoints. Use them to generate types
or validate payloads.

| Tool | URL | Notes |
| ---- | --- | ----- |
| **OpenAPI Schema** | [`/api/v1/schema/`](/api/v1/schema/) | Downloadable OpenAPI 3.1 spec; webhook bodies live under the top-level `webhooks` key, one per event. |
| **ReDoc** | [`/api/v1/redoc/`](/api/v1/redoc/) | Renders the `webhooks` section under its own heading. |

Each event contributes one webhook (named in camelCase — `listing.refreshed`
→ `listingRefreshed`) tagged **Webhooks**, documenting its body schema and
the Standard Webhooks headers.

### Next Steps

- [Account Created](account-created.md) (`account.created`) — a new managed
  account's listings have all been created.
- [Account Refreshed](account-refreshed.md) (`account.refreshed`) — a managed
  account's listings have all been refreshed.
- [Listing Created](listing-created.md) (`listing.created`) — a new listing is
  fully set up.
- [Listing In Active Market Changed](in-active-market-changed.md)
  (`listing.in_active_market_changed`) — a listing's `in-active-market`
  attribute changed.
- [Listing Refresh](listing-refreshed.md) (`listing.refreshed`) —
  reservations refresh completed.
- [Listing Base Price Changed](base-price-changed.md)
  (`listing.base_price_changed`) — a listing's effective base price changed.
- [Webhook Ping](webhook-ping.md) (`webhook.ping`) — a personal user's
  endpoint is being verified.

---

## Account Created

Sent when the **background listing sync** that follows adding a managed account
reaches a terminal state — every listing on the account has been pulled from the
channel and created in Beyond, or the sync failed. Use it to learn when an
account you just connected is ready, without polling.

| | |
| --- | --- |
| **Event type** | `account.created` |
| **Resource type** | `account-created-events` |
| **Triggered by** | [`POST /api/v1/users/{user_id}/accounts/`](../endpoints/managed-accounts.md#add-account) |

### When It Fires

The create-account endpoint validates your credentials against the channel and
returns `201 Created` right away. The real work — pulling every listing on the
account — happens afterwards, in the background:

```mermaid
flowchart LR
    A[POST /users/id/accounts/] --> B[Validate credentials]
    B -- invalid --> X[422 / 502 response<br/>no event]
    B -- valid --> C[201 Created]
    C --> D[Background: pull listings<br/>from the channel]
    D --> E{{account.created}}
```

This event fires exactly once when the background sync reaches a terminal state:

- **`status: succeeded`** — every listing on the account synced without error.
- **`status: failed`** — at least one listing failed, or the sync itself did not
  complete. The `error` attribute carries a short, human-readable summary.

Because a delivery retry re-uses the same `webhook-id`, dedupe on it (see
[idempotency](index.md#idempotency)).

!!! info "`succeeded` means listings *exist*, not that they are *priced*"
    Each listing is priced independently after it is created. A listing becomes
    fully set up when its initial base price is computed, and emits its own
    [`listing.created`](listing-created.md) event at that point. Wait for
    `account.created` to know the account's listings are all present; wait for
    each `listing.created` to know a listing is priced.

!!! note "Credential errors are reported synchronously"
    Invalid credentials, an unsupported 2FA prompt, or a channel outage are
    returned on the `POST` response itself (`422` / `502`) and emit **no** event
    — the account is never created, so there is nothing to sync.

#### Re-created accounts

Deleting a managed account and adding it again with the same channel and
credentials **revives** the original account and restores its listings rather
than creating new ones. `account.created` fires the same way in that case, with
the same fields; the restored listings are counted in `nb-listings-synced`.

### Payload

**Succeeded**

```json
{
  "meta": {
    "type": "account.created",
    "sent-at": "2026-07-03T12:34:56Z"
  },
  "data": {
    "type": "account-created-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "succeeded",
      "completed-at": "2026-07-03T12:34:56Z",
      "channel": "hostaway",
      "nb-listings-synced": 40,
      "nb-listings-failed": 0
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

**Failed (some listings)**

```json
{
  "meta": {
    "type": "account.created",
    "sent-at": "2026-07-03T12:40:00Z"
  },
  "data": {
    "type": "account-created-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "failed",
      "completed-at": "2026-07-03T12:40:00Z",
      "channel": "hostaway",
      "nb-listings-synced": 38,
      "nb-listings-failed": 2,
      "error": "2 listing(s) failed to sync"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" }
      }
    }
  }
}
```

**Failed (sync did not complete)**

```json
{
  "meta": {
    "type": "account.created",
    "sent-at": "2026-07-03T12:40:00Z"
  },
  "data": {
    "type": "account-created-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "failed",
      "completed-at": "2026-07-03T12:40:00Z",
      "channel": "hostaway",
      "nb-listings-synced": 0,
      "nb-listings-failed": null,
      "error": "The account's listing sync did not complete successfully"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `status` | string | Terminal outcome: `succeeded` or `failed`. |
| `completed-at` | string (RFC 3339) | UTC timestamp when the sync reached its terminal state. |
| `channel` | string | The channel the account is connected to (e.g. `airbnb`, `hostaway`). |
| `nb-listings-synced` | integer | Number of listings successfully created — or, for a re-created account, restored — on this account by the sync. |
| `nb-listings-failed` | integer \| null | Number of listings the sync could not create. Always present; `0` when `status` is `succeeded`. It is `null` when the sync did not run to completion (an infrastructure failure, or a channel-wide error), so the per-listing count is unknown — in that case `nb-listings-synced` reports how many listings exist on the account. |
| `error` | string \| null | Short, human-readable failure description. Present **only** when `status` is `failed`; stable across retries of the same delivery. |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `user` | The [user](../endpoints/users.md) the account belongs to (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) that was created (`{ "type": "accounts", "id": … }`). |

### Handling Tips

- On `succeeded`, the account's listings all exist — a good moment to `GET`
  [`/listings/`](../endpoints/listings.md) for the user. Their base prices arrive
  shortly after, each announced by a [`listing.created`](listing-created.md)
  event.
- On `failed` with a numeric `nb-listings-failed`, some listings did sync: read
  the listings you have, and re-add the account (or contact support) to retry the
  rest. A `null` means the sync did not finish and nothing can be concluded about
  individual listings.
- Do not treat `nb-listings-synced` as the account's permanent listing count —
  listings are added and removed on the channel over time. Follow
  `account.links.related` or list the user's listings for current state.

---

## Account Refreshed

Sent when the **background listing sync** started by refreshing a managed account
reaches a terminal state — every listing the refresh considered has been
re-pulled from the channel, or the sync failed. Use it to learn when a refresh
you triggered is done, without polling.

| | |
| --- | --- |
| **Event type** | `account.refreshed` |
| **Resource type** | `account-refreshed-events` |
| **Triggered by** | [`POST /api/v1/users/{user_id}/accounts/{account_id}/refresh/`](../endpoints/managed-accounts.md#refresh-account) |

### When It Fires

The refresh endpoint enqueues the sync and returns `202 Accepted` right away.
The real work happens afterwards, in the background:

```mermaid
flowchart LR
    A[POST /users/id/accounts/id/refresh/] --> B[202 Accepted]
    B --> C[Background: re-pull listings<br/>from the channel]
    C --> D{{account.refreshed}}
    C --> E[Reservations refresh<br/>continues in the background]
```

This event fires exactly once per accepted refresh, when the listing sync reaches
a terminal state:

- **`status: succeeded`** — every listing the refresh considered synced without
  error.
- **`status: failed`** — at least one listing failed, or the sync itself did not
  complete. The `error` attribute carries a short, human-readable summary.

Because a delivery retry re-uses the same `webhook-id`, dedupe on it (see
[idempotency](index.md#idempotency)).

!!! warning "Listings only — reservations are still in flight"
    The event describes the **listing** sync. Each listing's reservations are
    refreshed by an independent background job that outlives this event, so
    reservations may still be arriving when it is delivered. There is no
    reservation-completion event today.

!!! info "Skipped listings are not counted"
    `recent-sync-threshold-minutes` (default `60`) tells the refresh to skip
    listings synced within that window. Skipped listings never enter the sync, so
    they are **not** counted in `nb-listings-synced`. A refresh that skips every
    listing is still `succeeded`, with `nb-listings-synced` of `0`. The threshold
    the refresh ran with is echoed back as `recent-sync-threshold-minutes` so you
    can interpret the count. Pass `recent-sync-threshold-minutes=0` to force a
    full refresh.

!!! note "`status` is all-or-nothing"
    `succeeded` requires *every* considered listing to sync cleanly. One failure
    out of 500 yields `status: failed` with `nb-listings-synced: 499`. Read the
    counts, not just the status.

### Payload

**Succeeded**

```json
{
  "meta": {
    "type": "account.refreshed",
    "sent-at": "2026-07-10T12:34:56Z"
  },
  "data": {
    "type": "account-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "succeeded",
      "completed-at": "2026-07-10T12:34:56Z",
      "channel": "hostaway",
      "nb-listings-synced": 40,
      "nb-listings-failed": 0,
      "recent-sync-threshold-minutes": 60
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

**Failed (some listings)**

```json
{
  "meta": {
    "type": "account.refreshed",
    "sent-at": "2026-07-10T12:40:00Z"
  },
  "data": {
    "type": "account-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "failed",
      "completed-at": "2026-07-10T12:40:00Z",
      "channel": "hostaway",
      "nb-listings-synced": 38,
      "nb-listings-failed": 2,
      "recent-sync-threshold-minutes": 0,
      "error": "2 listing(s) failed to sync"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" }
      }
    }
  }
}
```

**Failed (sync did not complete)**

```json
{
  "meta": {
    "type": "account.refreshed",
    "sent-at": "2026-07-10T12:40:00Z"
  },
  "data": {
    "type": "account-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "failed",
      "completed-at": "2026-07-10T12:40:00Z",
      "channel": "hostaway",
      "nb-listings-synced": 40,
      "nb-listings-failed": null,
      "recent-sync-threshold-minutes": 60,
      "error": "The account's listing sync did not complete successfully"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `status` | string | Terminal outcome: `succeeded` or `failed`. |
| `completed-at` | string (RFC 3339) | UTC timestamp when the refresh reached its terminal state. |
| `channel` | string | The channel the account is connected to (e.g. `airbnb`, `hostaway`). |
| `nb-listings-synced` | integer | Number of listings the refresh successfully re-synced. Excludes listings skipped as recently synced. |
| `nb-listings-failed` | integer \| null | Number of listings the refresh could not sync. Always present; `0` when `status` is `succeeded`. It is `null` when the sync did not run to completion (an infrastructure failure, or a channel-wide error), so the per-listing count is unknown — in that case `nb-listings-synced` reports how many listings exist on the account. |
| `recent-sync-threshold-minutes` | integer | The threshold the refresh ran with, echoed from the request. Listings synced within this many minutes were skipped; `0` means none were. |
| `error` | string \| null | Short, human-readable failure description. Present **only** when `status` is `failed`; stable across retries of the same delivery. |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `user` | The [user](../endpoints/users.md) the account belongs to (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) that was refreshed (`{ "type": "accounts", "id": … }`). |

### Handling Tips

- On `succeeded`, the account's listings reflect the channel as of
  `completed-at` — a good moment to `GET`
  [`/listings/`](../endpoints/listings.md) for the user.
- On `failed` with a numeric `nb-listings-failed`, some listings did sync: read
  the listings you have and re-trigger the refresh for the rest. A `null` means
  the sync did not finish and nothing can be concluded about individual listings.
- If `nb-listings-synced` is lower than you expect, check
  `recent-sync-threshold-minutes` — those listings were already fresh.
- Reservation data is not guaranteed to be current when this event arrives.

---

## Listing Created

Sent when a **new listing** owned by one of your users is fully set up — and,
with `status: "failed"`, when an attempt to set one up fails. Use it to learn
about new listings the moment they are ready, and about listings that need
attention before they can be created, instead of polling the
[Listings](../endpoints/listings.md) endpoint.

| | |
| --- | --- |
| **Event type** | `listing.created` |
| **Resource type** | `listing-created-events` |
| **Triggered by** | A new listing finishing setup — its initial base price is computed — a previously removed listing coming back, or a sync attempt failing to create one |

### When It Fires

A listing is not ready to price against the moment its row is created: Beyond
first clusters it, resolves its market, imports its calendar, and computes an
**initial base price** for it. With `status: "succeeded"`, this event fires at
that last step — the first time the listing's base price is computed — so when
you receive it the listing is fully set up and its `base-price` is populated.

It fires once on that first pricing, and **again if the listing is ever removed
and later comes back** — see [Restored listings](#restored-listings) below.
Treat the event as an upsert rather than an insert. Later base-price changes
are delivered as [`listing.base_price_changed`](base-price-changed.md) instead,
and the listing later leaving or re-entering the fully-priceable state is
announced via
[`listing.in_active_market_changed`](in-active-market-changed.md).

#### Restored Listings

Deleting a managed account removes its listings from your inventory, but the
listings are kept: re-creating the account with the same channel credentials
brings **the same listings back**, at the same listing ids. The same is true of
a listing that disappears from the channel long enough to be removed and later
reappears.

Because the listing already has its base price, the first-pricing milestone
above cannot fire a second time — so the return is announced with a
`status: "succeeded"` event carrying `restored: true`. Everything else about
the payload matches a first-time creation, including `created-at`, which is
still the listing's **original** creation timestamp; `meta.sent-at` is when it
came back.

The `restored` attribute is **omitted entirely** for a first-time creation, so
a payload that never had it is unchanged.

With `status: "failed"`, the event fires when a sync attempt fails to create a
listing that has not been set up yet — for example a property whose channel
setup is incomplete (missing rates, misconfigured rate plans) or an unexpected
error while importing it. The `error` attribute carries a human-readable
description. Unlike the succeeded case, a failed event fires on **every**
failing sync attempt: a listing whose setup stays broken repeats the event on
each re-sync (accounts re-sync daily), until it is either fixed — at which
point a `status: "succeeded"` event follows — or removed.

The webhook is delivered to the OAuth2 application that owns the listing's user
(`User.owner_application`). Listings owned by ordinary, non-API users have no
owning application and produce no event.

!!! note "First base price, then changes"
    `listing.created` carries the listing's **initial** base price. To keep
    tracking that value over time, subscribe to
    [`listing.base_price_changed`](base-price-changed.md), which fires on every
    subsequent change.

### Payload

```json
{
  "meta": {
    "type": "listing.created",
    "sent-at": "2026-07-06T12:05:00Z"
  },
  "data": {
    "type": "listing-created-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E8",
    "attributes": {
      "status": "succeeded",
      "created-at": "2026-07-06T12:00:00Z",
      "title": "Ocean View Retreat",
      "currency": "USD",
      "base-price": 210,
      "channel-listings": [
        { "channel": "airbnb", "channel-id": "external-listing-987" }
      ]
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### Restored Listing

A listing that came back. Identical to the payload above apart from
`restored`, and the ids are the ones you already know.

```json
{
  "meta": {
    "type": "listing.created",
    "sent-at": "2026-08-01T09:14:00Z"
  },
  "data": {
    "type": "listing-created-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7EA",
    "attributes": {
      "status": "succeeded",
      "restored": true,
      "created-at": "2026-07-06T12:00:00Z",
      "title": "Ocean View Retreat",
      "currency": "USD",
      "base-price": 210,
      "channel-listings": [
        { "channel": "airbnb", "channel-id": "external-listing-987" }
      ]
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

#### Failed Creation Attempt

On a failed attempt the listing usually does not exist yet, so the
listing-specific attributes are `null` and the `listing` relationship is
omitted; `channel-listings` and the `account`/`user` relationships tell you
which property failed. The `meta.sent-at` envelope timestamp is when the
failure was observed.

```json
{
  "meta": {
    "type": "listing.created",
    "sent-at": "2026-07-07T12:05:00Z"
  },
  "data": {
    "type": "listing-created-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E9",
    "attributes": {
      "status": "failed",
      "error": "Room rates are not configured for this property. Please configure a rate in your PMS.",
      "created-at": null,
      "title": null,
      "currency": null,
      "base-price": null,
      "channel-listings": [
        { "channel": "mews", "channel-id": "external-listing-987" }
      ]
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `status` | string | Outcome of the listing setup: `succeeded` or `failed`. |
| `error` | string | Human-readable description of what went wrong. **Present only when `status` is `failed`.** |
| `restored` | boolean | `true` when this listing was previously removed and has now come back (see [Restored listings](#restored-listings)). **Omitted entirely for a first-time creation** and on a failed attempt. |
| `created-at` | string (RFC 3339) \| null | UTC timestamp when the listing was created. `null` on a failed attempt where the listing does not exist yet. |
| `title` | string \| null | The listing's title. `null` on a failed attempt. |
| `currency` | string \| null | ISO 4217 currency code the listing is denominated in. `null` on a failed attempt. |
| `base-price` | integer \| null | The listing's **initial** base price, computed by Beyond during setup, in the listing currency — the same whole number the [listing resource](../endpoints/listings.md#get-listing-details) exposes as `base-price` (fractional amounts are truncated toward zero). `null` on a failed attempt. |
| `channel-listings` | array of objects | The listing's **active channel listings** — one entry per channel the listing is syndicated to, the same `channel-listings` exposed on the [listing resource](../endpoints/listings.md#get-listing-details). Empty (`[]`) on the rare listing with no active channel listing. On a failed attempt it holds the single channel listing that could not be set up (empty when the channel is unknown). Each entry has `channel` (the channel, e.g. `airbnb`, `hostaway`) and `channel-id` (the listing's identifier **on the channel side**, for correlating the event with your own records). |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `listing` | The Beyond Pricing master listing that was created (`{ "type": "listings", "id": … }`). Omitted on a failed attempt where the listing does not exist yet. |
| `user` | The [user](../endpoints/users.md) who owns the listing (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) the listing's primary channel listing belongs to (`{ "type": "accounts", "id": … }`). Omitted on the rare listing with no active channel listing. |

### Handling Tips

- Check `status` first. Treat `succeeded` as "a new listing is ready" — by the
  time you receive it, the listing endpoint returns its full representation,
  including the `base-price` shown here.
- Follow the `listing.links.related` URL to fetch the listing's current
  representation rather than assuming its contents from this event.
- Dedupe on `webhook-id` (see [idempotency](index.md#idempotency)) since retries
  re-use it. That handles retries, not repeats: a re-onboarded listing fires a
  genuinely new event with a different `webhook-id`. **Upsert on the `listing`
  relationship id** rather than inserting, so a listing you already know about
  is updated instead of duplicated; `restored: true` tells you when to expect it.
- A `failed` event repeats on every failing sync attempt, so expect roughly one
  per broken property per day until it is fixed. Use the
  `channel-listings[].channel-id` + the `account` relationship to group repeats
  for the same property; surface the `error` to whoever can fix the channel setup.
- A `failed` event does not mean the listing never gets created: once the setup
  issue is fixed, the next sync creates it and a `succeeded` event follows.

### Known Limitations

- Failure events cover errors raised while importing a property (channel setup
  problems and unexpected sync errors). A listing that imports cleanly but
  never gets an initial base price (for example, its market is not yet active,
  or it falls outside any pricing cluster) currently produces **no** event —
  neither succeeded nor failed. Both cases surface as `in-active-market:
  false` on the [listing resource](../endpoints/listings.md); once a listing
  has a base price, later changes of that attribute are announced via
  [`listing.in_active_market_changed`](in-active-market-changed.md).
- Transient infrastructure errors (timeouts, worker restarts) are retried
  internally and do not fire failure events.

---

## Listing In Active Market Changed

Sent when a listing's
[`in-active-market`](../endpoints/listings.md#pricing-readiness-in-active-market)
attribute — the one the [Listings](../endpoints/listings.md) endpoint serves —
changes, and once at creation for a listing that starts out `false`.
`in-active-market: true` guarantees the listing's
[calendar endpoint](../endpoints/calendar.md) works and automated price
refresh is on; while `false` the calendar may return a `400` ("not yet
clustered") and price refresh is off. Use it to learn when a listing leaves or
re-enters that ready state, instead of polling.

| | |
| --- | --- |
| **Event type** | `listing.in_active_market_changed` |
| **Resource type** | `listing-in-active-market-changed-events` |
| **Triggered by** | A listing's `in-active-market` attribute changing, in either direction — or a new listing being created with `in-active-market: false` |

### When It Fires

#### At creation

A listing created **outside** any active market — Beyond found no pricing
cluster covering its location — fires this event immediately with
`in-active-market: false`. These listings need attention: their calendar may
return `400`, their prices do not refresh, and they receive no
[`listing.created`](listing-created.md) event, because that milestone marks a
listing that is fully set up *and* priced. For such a listing this event is
therefore the **first** notification you receive about it; follow the
`listing.links.related` URL to fetch it.

A listing created inside an active market fires nothing here — its milestone is
[`listing.created`](listing-created.md).

#### On a later change

The transition is rare. `in-active-market` turns `false` when Beyond removes
the listing's pricing cluster (for example after deleting a cluster with no
active replacement covering the listing's location, or because a wrong or
imprecise address moved the property outside any area Beyond covers — such as
into the ocean) or deactivates the listing's market; it turns `true` again when
a cluster is re-assigned in an active market, or the market activates. Internal
reshuffling that keeps the listing ready throughout — such as moving it
directly from one cluster to another — fires nothing.

While `in-active-market` is `false` the listing is still returned by the
[Listings](../endpoints/listings.md) endpoint, but its calendar may return
`400` and its prices stop refreshing. When the event arrives with
`in-active-market: true`, the calendar is available again.

A market-wide change (for example Beyond activating a market) reaches each
listing on its next daily sync, so expect one event per affected listing
spread over about a day rather than a single burst.

### Payload

```json
{
  "meta": {
    "type": "listing.in_active_market_changed",
    "sent-at": "2026-07-16T12:05:00Z"
  },
  "data": {
    "type": "listing-in-active-market-changed-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E8",
    "attributes": {
      "in-active-market": true,
      "changed-at": "2026-07-16T12:00:00Z",
      "title": "Ocean View Retreat",
      "channel-listings": [
        { "channel": "airbnb", "channel-id": "external-listing-987" }
      ]
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `in-active-market` | boolean | The attribute's **new** value — the same boolean the [listing resource](../endpoints/listings.md#pricing-readiness-in-active-market) serves. `true`: the calendar endpoint works and automated price refresh is on. `false`: the calendar may return `400` and price refresh is off. |
| `changed-at` | string (RFC 3339) | UTC timestamp when the attribute changed. |
| `title` | string \| null | The listing's title. |
| `channel-listings` | array of objects | The listing's **active channel listings** — one entry per channel the listing is syndicated to, the same `channel-listings` exposed on the [listing resource](../endpoints/listings.md#get-listing-details). Empty (`[]`) on the rare listing with no active channel listing. Each entry has `channel` (the channel, e.g. `airbnb`, `hostaway`) and `channel-id` (the listing's identifier **on the channel side**, for correlating the event with your own records). |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `listing` | The Beyond Pricing master listing whose attribute changed (`{ "type": "listings", "id": … }`). |
| `user` | The [user](../endpoints/users.md) who owns the listing (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) the listing's primary channel listing belongs to (`{ "type": "accounts", "id": … }`). Omitted on the rare listing with no active channel listing. |

### Handling Tips

- Route on the boolean: `in-active-market: false` means "stop reading the
  calendar and stop treating previously fetched prices as current";
  `true` means the calendar is available again.
- Follow the `listing.links.related` URL to fetch the listing's current
  representation rather than assuming its contents from this event.
- Dedupe on `webhook-id` (see [idempotency](index.md#idempotency)) since
  retries re-use it. A listing that flips more than once fires once per flip,
  each with a fresh `webhook-id`.

### Known Limitations

- Delivery order between two events for the same listing is not guaranteed —
  order them by `changed-at`, or fetch the listing for its current state.
- The creation event can arrive before you have seen the listing through any
  other endpoint or event. Treat an unknown `listing.id` as new rather than as
  an error.

---

## Listing Refresh

Sent when a partner-triggered listing **refresh chain terminates** — either
every step succeeds, or one fails. Use it to learn when a refresh you kicked off
has finished, without polling.

| | |
| --- | --- |
| **Event type** | `listing.refreshed` |
| **Resource type** | `listing-refreshed-events` |
| **Triggered by** | [`POST /api/v1/listings/{id}/refresh/`](../endpoints/listings.md#listing-refresh) |

### When It Fires

Calling the [listing refresh
endpoint](../endpoints/listings.md#listing-refresh) queues an asynchronous
chain for the listing's primary channel listing:

```mermaid
flowchart LR
    A[POST /listings/id/refresh/] --> B[Sync listing<br/>details and availability]
    B --> C[Sync reservations]
    C --> D[Recompute automatic base price]
    D --> E{{listing.refreshed}}
    B -. any step fails .-> E
```

This event fires exactly once when the chain reaches a terminal state:

- **`status: succeeded`** — the reservations sync **and** the automatic
  base-price recompute both completed.
- **`status: failed`** — at least one step in the chain raised. The `error`
  attribute carries a short, human-readable summary.

The endpoint returns `202 Accepted` immediately; this webhook is how you find
out the queued work is done. Because a delivery retry re-uses the same
`webhook-id`, dedupe on it (see [idempotency](index.md#idempotency)).

### Payload

**Succeeded**

```json
{
  "meta": {
    "type": "listing.refreshed",
    "sent-at": "2026-07-03T12:34:56Z"
  },
  "data": {
    "type": "listing-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "succeeded",
      "completed-at": "2026-07-03T12:34:56Z",
      "channel": "hostaway",
      "channel-listing-id": "external-listing-987"
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

**Failed**

```json
{
  "meta": {
    "type": "listing.refreshed",
    "sent-at": "2026-07-03T12:40:00Z"
  },
  "data": {
    "type": "listing-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "status": "failed",
      "completed-at": "2026-07-03T12:40:00Z",
      "channel": "hostaway",
      "channel-listing-id": "external-listing-987",
      "error": "The operation did not complete successfully."
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" }
      },
      "user": {
        "data": { "type": "users", "id": "456" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `status` | string | Terminal outcome: `succeeded` or `failed`. |
| `completed-at` | string (RFC 3339) | UTC timestamp when the terminal job finished. |
| `channel` | string | The channel the refreshed listing belongs to (e.g. `airbnb`, `hostaway`). |
| `channel-listing-id` | string | The refreshed listing's identifier **on the channel side** — the same value exposed as `channel-listings[].channel-id` on the [listing resource](../endpoints/listings.md#get-listing-details). Use it to correlate the event with your own records. |
| `error` | string \| null | Short, human-readable failure description. Present **only** when `status` is `failed`; stable across retries of the same delivery. |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `listing` | The Beyond Pricing master listing that was refreshed (`{ "type": "listings", "id": … }`). |
| `user` | The [user](../endpoints/users.md) who owns the listing (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) the refreshed channel listing belongs to (`{ "type": "accounts", "id": … }`). |

### Handling Tips

- On `succeeded`, the listing's reservations and automatic base price are now
  up to date — a good moment to re-read the
  [listing](../endpoints/listings.md#get-listing-details) or
  [calendar](../endpoints/calendar.md) if you cache them.
- On `failed`, surface or log `error` and consider retrying the refresh later;
  the failure is specific to that refresh chain, not a permanent listing state.
- Follow the `listing.links.related` URL to fetch the listing's current
  representation rather than assuming its contents from this event.

---

## Listing Base Price Changed

Sent whenever a listing's **effective base price changes** — through the
Beyond API or any other Beyond surface. Use it to keep your own records in
sync with the base price Beyond Pricing anchors its algorithm to. The
`enabled` attribute tells you whether that price is live or still speculative.

| | |
| --- | --- |
| **Event type** | `listing.base_price_changed` |
| **Resource type** | `listing-base-price-changed-events` |
| **Triggered by** | A change to a listing's effective base price (see below) |

### When It Fires

This event tracks the listing's **effective** base price — whichever value
Beyond currently anchors its algorithm to. That is the user-set base price when
one exists, and otherwise the starting base price Beyond's algorithm computes
for the listing from its characteristics.

It fires whenever that effective price changes, in **any** code path — not only
the Beyond API. That includes:

- The [Base Price
  customization](../endpoints/listing-customizations.md#base-price) endpoint.
- The Beyond web app.
- Bulk edits and other internal tools.
- A change to the algorithm's computed starting price, while no user base price
  is set.

Two rules narrow when the computed starting price fires:

- It fires only on a change from one value to another. The **first-ever** price
  (`null` → a value) is a listing becoming ready for the first time and is
  delivered as [`listing.created`](listing-created.md) instead, not here.
- While a **user** base price is set, the computed starting price is dormant: its
  changes do not fire, and only user base-price changes do.

Changing the listing's **currency** also fires the event, even when the numeric
base price is unchanged — a currency re-denomination counts as a base-price
change.

Use the `enabled` attribute to tell a **live** change (`enabled: true`) from a
**speculative** one (`enabled: false`). A change to the computed starting price
always arrives with `enabled: false`, because a listing cannot be enabled until
a user base price is set — so it signals a price Beyond is preparing, not one yet
in effect.

The webhook is delivered to the OAuth2 application that owns the listing's user
(`User.owner_application`). Listings owned by ordinary, non-API users have no
owning application and produce no event.

!!! note "Not the modeled nightly price"
    This event is about the **base price** — the user-set anchor Beyond's
    algorithm prices around — not the per-night recommended price. For nightly
    prices, read the [calendar](../endpoints/calendar.md).

### Payload

**Price change**

```json
{
  "meta": {
    "type": "listing.base_price_changed",
    "sent-at": "2026-07-03T14:05:00Z"
  },
  "data": {
    "type": "listing-base-price-changed-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E8",
    "attributes": {
      "new-base-price": 210,
      "old-base-price": 185,
      "new-currency": "USD",
      "old-currency": "USD",
      "changed-at": "2026-07-03T14:05:00Z",
      "channel-listings": [
        { "channel": "hostaway", "channel-id": "external-listing-987" }
      ],
      "enabled": true,
      "change-source": "user",
      "changed-by": "owner@example.com"
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

**First time set**

```json
{
  "meta": {
    "type": "listing.base_price_changed",
    "sent-at": "2026-07-03T14:05:00Z"
  },
  "data": {
    "type": "listing-base-price-changed-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E8",
    "attributes": {
      "new-base-price": 185,
      "old-base-price": null,
      "new-currency": "USD",
      "old-currency": null,
      "changed-at": "2026-07-03T14:05:00Z",
      "channel-listings": [
        { "channel": "hostaway", "channel-id": "external-listing-987" }
      ],
      "enabled": false,
      "change-source": "user",
      "changed-by": "owner@example.com"
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

**Computed starting price (speculative)**

```json
{
  "meta": {
    "type": "listing.base_price_changed",
    "sent-at": "2026-07-03T14:05:00Z"
  },
  "data": {
    "type": "listing-base-price-changed-events",
    "id": "msg_01J9Z7Q1H4RM8ZW9P3D5C6B7E8",
    "attributes": {
      "new-base-price": 195,
      "old-base-price": 180,
      "new-currency": "USD",
      "old-currency": "USD",
      "changed-at": "2026-07-03T14:05:00Z",
      "channel-listings": [
        { "channel": "hostaway", "channel-id": "external-listing-987" }
      ],
      "enabled": false,
      "change-source": "system",
      "changed-by": null
    },
    "relationships": {
      "listing": {
        "data": { "type": "listings", "id": "12345" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/listings/12345/" }
      },
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      },
      "account": {
        "data": { "type": "accounts", "id": "789" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/accounts/789/" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `new-base-price` | integer | The listing's new base price, in the listing currency — the same whole number the [listing resource](../endpoints/listings.md#get-listing-details) exposes as `base-price` (fractional amounts are truncated toward zero). |
| `old-base-price` | integer \| null | The previous base price as a whole number, or `null` the first time a base price is set on the listing. |
| `new-currency` | string \| null | ISO 4217 currency code the new base price is denominated in. |
| `old-currency` | string \| null | ISO 4217 currency code of the previous base price. Differs from `new-currency` **only** on a currency re-denomination; equal to it for a plain price change. |
| `changed-at` | string (RFC 3339) \| null | UTC timestamp when the base price changed. |
| `channel-listings` | array of objects | The listing's **active channel listings** — one entry per channel the listing is syndicated to, the same `channel-listings` exposed on the [listing resource](../endpoints/listings.md#get-listing-details). Empty (`[]`) on the rare listing with no active channel listing. Each entry has `channel` (the channel, e.g. `airbnb`, `hostaway`) and `channel-id` (the listing's identifier **on the channel side**, for correlating the event with your own records). |
| `enabled` | boolean | Whether the listing has pricing enabled. `true` means the change is **live**; `false` means it is **speculative** — e.g. a change to the algorithm's computed starting price (always `false`), or a user editing the base price before turning syncing on. |
| `change-source` | string (`user` \| `system`) | What moved the base price. `user` — an authenticated actor made the change (the Beyond API, the Beyond app, or an admin). `system` — an automatic process made it (Beyond's algorithm computing a starting price, a scheduled auto-approved booking review, a background job). A change to the computed starting price is always `system`. Note this reflects **who was authenticated**, not who computed the value: a person accepting a Beyond-generated recommendation is `user`. |
| `changed-by` | string \| null | Email of the individual who made a `user` change. `null` whenever no individual is responsible: every `system` change, and any `user` change made by a machine — an app-level (`client_credentials`) Beyond API token acts for the account as a whole, not for a person, so it names nobody. Changes made by Beyond staff on your behalf report `support@beyondpricing.com` rather than an internal address. |

#### `data.relationships`

Every relationship carries a `links.related` URL you can `GET` for the current
state of the related resource.

| Relationship | Description |
| ------------ | ----------- |
| `listing` | The Beyond Pricing master listing whose base price changed (`{ "type": "listings", "id": … }`). |
| `user` | The [user](../endpoints/users.md) who owns the listing (`{ "type": "users", "id": … }`). |
| `account` | The [managed account](../endpoints/managed-accounts.md) the listing's primary channel listing belongs to (`{ "type": "accounts", "id": … }`). Omitted on the rare listing with no active channel listing. |

### Handling Tips

- Treat `old-base-price: null` as "base price set for the first time" rather
  than a decrease from zero.
- Branch on `enabled` to decide how to react: `enabled: true` is a price that is
  live now; `enabled: false` is speculative (the algorithm's computed starting
  price, or a pre-enable edit) and you may want to stage rather than apply it. A
  change to the computed starting price always arrives with `enabled: false`.
- Compare `old-currency` and `new-currency` to tell a **currency
  re-denomination** apart from a plain price change: when they differ, the
  numeric value moved because the currency changed, not because the price was
  re-negotiated.
- Branch on `change-source` to tell an **actor-initiated** change (`user`) from
  an **automatic** one (`system`) — e.g. to skip mirroring Beyond's own
  algorithmic adjustments, or to only react to human-driven edits. It reflects
  who was authenticated, not who computed the value.
- Use `changed-by` to attribute a `user` change to a person, but do not treat a
  `null` as "no one did it": edits made through an app-level Beyond API token
  are `change-source: user` with `changed-by: null`, because the token acts for
  the account rather than for an individual.
- Because this event fires from every surface, expect changes made in the Beyond
  app or by bulk operations, not only ones your integration initiated. Dedupe on
  `webhook-id` (see [idempotency](index.md#idempotency)) since retries re-use it.
- Follow the `listing.links.related` URL to fetch the listing's current
  representation rather than assuming its contents from this event.

---

## Webhook Ping

Sent to **verify an endpoint**: once when a webhook URL is saved from the
Beyond dashboard, and whenever a test ping is requested there. It carries no
domain change. Answer any `2xx` and discard the body.

| | |
| --- | --- |
| **Event type** | `webhook.ping` |
| **Resource type** | `webhook-ping-events` |
| **Triggered by** | Saving a webhook URL or sending a test ping from the dashboard (personal users) |

### When It Fires

A personal user configures their webhook from the Beyond dashboard. Beyond
persists a new URL **only after** a signed ping to it returns a `2xx`, and the
dashboard's "Send test ping" action sends the same ping on demand. The ping is
signed with the user's signing secret like every other delivery, so a receiver
that enforces signatures from day one passes verification.

```mermaid
sequenceDiagram
    participant U as Dashboard
    participant BP as Beyond Pricing
    participant P as Your Endpoint

    U->>BP: Save URL
    BP->>P: POST signed webhook.ping (no redirects, timeout)
    alt Any 2xx
        P-->>BP: 200 OK
        BP-->>U: URL saved, verified
    else 3xx / other status / timeout / network error
        P-->>BP: error
        BP-->>U: URL not saved, reason shown
    end
```

A ping is **not gated on your event selection**: it is sent even when
`webhook.ping` is not in your subscribed events, because its purpose is to
confirm the endpoint before any real event is delivered. It is sent once and
never retried; a failed ping changes nothing on Beyond's side.

### Payload

```json
{
  "meta": {
    "type": "webhook.ping",
    "sent-at": "2026-08-20T12:34:56Z"
  },
  "data": {
    "type": "webhook-ping-events",
    "id": "msg_01K2Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": {
      "message": "Beyond webhook endpoint verification"
    },
    "relationships": {
      "user": {
        "data": { "type": "users", "id": "456" },
        "links": { "related": "https://developers.beyondpricing.com/api/v1/users/456/" }
      }
    }
  }
}
```

The `meta` envelope is documented in the [Payload
Envelope](index.md#payload-envelope) section.

#### `data.attributes`

| Field | Type | Description |
| ----- | ---- | ----------- |
| `message` | string | Human-readable reason for the ping. Informational only. |

#### `data.relationships`

| Relationship | Description |
| ------------ | ----------- |
| `user` | The Beyond user whose webhook is being verified (`{ "type": "users", "id": … }`). |

### Handling Tips

- Verify the signature, then return `200` (or any `2xx`) without doing any
  work. A slow handler can hit the timeout and the URL is not saved.
- Do not follow the event with a lookup: the ping describes no change.
- Route on `meta.type` and treat `webhook.ping` like any other unknown type:
  acknowledge and ignore.

---

# Release Notes

This page documents changes to the Beyond API. Entries are organized by API
version, then by date (newest first), and grouped into categories.

For details on what constitutes a breaking vs. non-breaking change, see
[API Versioning](guides/versioning.md).

## v1

### 2026-10-05

**Added**

- Listing refresh accepts an optional `sync-priority` query parameter that says how soon the refresh starts, like account creation. `high` starts it as soon as Beyond can; `normal` can take longer to start when Beyond is busy, which suits refreshing many listings at once. `high` is the default, so refreshes without the parameter behave as before. See [Sync priority](endpoints/listings.md#sync-priority).

### 2026-10-01

**Added**

- New `customizations/gap-price-adjustments/` endpoint to read and update a listing's gap price adjustments: the dynamic gap price adjustments toggle and the manual rules that discount or raise the nights of short gaps between bookings, with Beyond's default rules and whether the listing still uses them (`is-default`) returned read-only. The rules are also part of the `customizations/` aggregate. See [Gap Price Adjustments](endpoints/listing-customizations.md#gap-price-adjustments).
- Time-based adjustments responses now include `dynamic-time-based-adjustments.median-lead-time`: the median number of days between booking and arrival for similar-sized listings in the listing's neighborhood, as shown on the Beyond dashboard, or `null` when there is no estimate. The field is read-only and ignored on `PATCH`. See [Dynamic Time-Based Adjustments](endpoints/listing-customizations.md#dynamic-time-based-adjustments).

### 2026-09-30

**Added**

- New `checkin-checkout-days` listing customization: read and update the weekdays guests may check in and check out, year-round and per season, and the changeover gap fill. A read-only `channel-support` object reports what the listing's channel accepts. It is also returned by `GET /listings/{id}/customizations/`. See [Check-In and Check-Out Days](endpoints/listing-customizations.md#check-in-and-check-out-days).

**Fixed**

- A seasonal range with `rollover: true` must now be shorter than one year; a longer range returns `400`. Such a range was previously accepted and silently shortened, because a repeating season keeps only its month and day. This applies to every seasonal list that accepts `rollover`.
- `seasonal-day-of-week-min-stays`, `seasonal-gap-fill-min-stays`, `seasonal-time-based-min-stays` and `seasonal-day-of-week-min-prices` now accept and return `rollover`, like the other seasonal lists. A season repeating every year was previously returned without the flag, so sending the body of a `GET` back deleted it, or kept it for the current year only.
- A `manual-overrides` PATCH mixing rows with and without `days-of-week` now applies each row to its own dates. Previously, when the first row of a mode (`price` or `percentage-adjustment`) had no `days-of-week`, later rows of that mode were applied to every date in their range. When a later row had none, the request returned `500`. Rows whose weekdays never fall on the same date are no longer rejected as overlapping.
- A `min-stays` PATCH with `seasonal-time-based-min-stays` rules now saves them. It previously returned `500` whenever a season had rules, so the body of a `GET` could not be sent back. A season must now have at least one rule unless `exclude` is `true`, and none when it is; either mistake returns `400`. A season with neither previously returned `500` or was silently dropped, and rules on an excluding season were silently discarded.
- Validation errors inside a list or nested object now point at the request member: field names in `source.pointer` are dasherized at every level (`.../seasonal-min-stays/0/start-date`, previously `start_date`), and an error about a whole item points at the item (`.../overrides/0`, previously `.../overrides/0/non_field_errors`).
- Overlapping ranges, a one-off range ending more than 5 years from today, and a manual override on a past date still return `400`, but `source.pointer` now names the offending list item instead of `/data`. A seasonal price whose minimum is above its maximum now returns `400` for `rollover` ranges too; it was previously stored.

### 2026-09-28

**Added**

- New `occupancy-based-pricing` listing customization: read and update whether occupancy-based pricing is on, its strategy `tier`, the `min-factor` / `max-factor` caps, and the `disabled-periods` when it is skipped. It is also returned by `GET /listings/{id}/customizations/`. See [Occupancy-Based Pricing](endpoints/listing-customizations.md#occupancy-based-pricing).

### 2026-09-23

**Changed**

- Market insights now benchmark studios (`0` bedrooms) and one-bedroom listings against one shared cohort, reported as `meta.cohort-bedrooms: ["0", "1"]`. Studios previously compared against studios only, which usually had no benchmark data because listing sites often list a studio as a one-bedroom. See [The benchmark](endpoints/insights.md#the-benchmark).

### 2026-08-25

**Fixed**

- Errors that previously came back as an HTML page now return the same JSON:API error document as every other error. This covers unexpected server errors (`500`), requests to a URL that is not an endpoint (`404`), and requests rejected before routing such as an oversized body or an unrecognized `Host` header (`400`). A client can now parse every response the API sends. See [Error Handling](guides/error-handling.md).

### 2026-08-20

**Added**

- Personal users can now receive webhooks. An administrator configures one endpoint per Beyond account from the dashboard's Personal Access Tokens page: generate a signing secret, save a public `https` URL, and pick the events to receive. See [For personal users](webhooks/index.md#for-personal-users).
- New `webhook.ping` event, sent to verify an endpoint when a URL is saved or a test ping is requested from the dashboard. It carries no change; acknowledge with any `2xx`. See [Webhook Ping](webhooks/webhook-ping.md).
- Personal access tokens now carry the `webhooks:read` scope, so a PAT can read back its own deliveries through the [Webhook Events](endpoints/webhook-events.md) endpoints.

**Fixed**

- `GET /compsets/` no longer lists comp sets whose anchor listing was deleted. Such comp sets always returned 404 on `GET /compsets/{id}/`, so a client following the list could request a comp set it was never able to fetch. Every comp set the list returns is now fetchable.

### 2026-08-18

**Added**

- Account creation accepts an optional `sync-priority` query parameter that says how soon the account's import starts. `high` starts it as soon as Beyond can; `normal` can take longer to start when Beyond is busy, which suits adding many accounts at once. `high` is the default, so accounts you add without the parameter behave as before. See [Sync priority](endpoints/managed-accounts.md#sync-priority).

**Changed**

- The account refresh endpoint's query parameter is now spelled `recent-sync-threshold-minutes`, dasherized like every other query parameter and field name in the API. The old snake_case spelling `recent_sync_threshold_minutes` is deprecated but still accepted, so existing integrations keep working unchanged. See [Refresh Account](endpoints/managed-accounts.md#refresh-account).

### 2026-08-12

**Added**

- Listings responses now include the `market` attribute: the Beyond market the listing belongs to (`null` while unassigned). See [Listings](endpoints/listings.md#listing-attributes).
- New listing filters `filter[name]` (case-insensitive title substring), `filter[market]`, `filter[bedrooms]`, and `filter[in-active-market]`. All filters remain optional; requests without them behave as before. See [Listings](endpoints/listings.md#list-listings).
- The `listing.created` webhook now carries a `restored` attribute, set to `true` when a listing you had already been told about comes back — after the managed account it belongs to is deleted and re-created, or after the listing reappears on the channel. The attribute is omitted entirely for a first-time creation. See [Listing Created](webhooks/listing-created.md#restored-listings).

**Changed**

- `listing.created` with `status: "succeeded"` no longer fires only once per listing: a listing that is removed and later comes back fires it again, carrying `restored: true`. Previously the return was silent, and a partner following a deletion had no way to learn the listing was back. Treat the event as an upsert on the `listing` relationship id rather than an insert.

### 2026-08-05

**Added**

- Market Insights is released and available to all Personal Users: benchmark a listing's daily occupancy and booked/posted rates against its neighborhood or market using a Personal Access Token with the `insights:read` scope. See [Market Insights](endpoints/insights.md).

### 2026-08-04

**Added**

- Take over pricing for specific dates with the new manual overrides customization: pin a fixed nightly price that Beyond will not reprice, or apply a percentage adjustment that keeps moving with the model, for a date range or selected weekdays. This is the same control the Beyond web calendar's "Manual Override" panel offers. See [Manual Overrides](endpoints/listing-customizations.md#manual-overrides).
- Calendar entries now include `price-override-type`, telling you whether a date's `price` is purely modeled (`null`), pinned to a fixed override (`fixed`), or a modeled price carrying a percentage override (`percentage`). See [Calendar](endpoints/calendar.md#manual-override-state).

### 2026-07-24

**Added**

- Configure your own webhook delivery with the new `webhooks:write` scope: choose which event types you receive and change your destination URL, without contacting Beyond. Applications keep receiving every event type by default. See [Webhook Configuration](endpoints/webhook-configuration.md).

### 2026-07-21

**Added**

- Calendar entries now include `metadata` on the pricing factors that carry it, explaining what drove the factor: listing and market occupancy for occupancy factors, bedroom count for pacing factors, and the triggering review date for reputation discounts. The object is omitted for factors that carry none. See [Calendar](endpoints/calendar.md#factor-metadata).

### 2026-07-20

**Added**

- Read back the webhook events Beyond has delivered to you, with their delivery status, using the new `webhooks:read` scope. Useful for reconciling and reprocessing events missed while your receiver was down. See [Webhook Events](endpoints/webhook-events.md).

### 2026-07-16

**Added**

- New `listing.in_active_market_changed` webhook: fires when a listing's `in-active-market` attribute changes in either direction — the listing leaves or re-enters the state where its calendar endpoint is guaranteed to work and automated price refresh is on. See [Listing In Active Market Changed](webhooks/in-active-market-changed.md).

### 2026-07-14

**Changed**

- The account refresh endpoint (`POST /users/{user_id}/accounts/{account_id}/refresh/`) now returns `409 Conflict` when a sync is already in progress for the account, instead of accepting the request with `202` and silently dropping it. Wait for the in-progress sync to finish (or for the `account.refreshed` webhook) and retry shortly. See [Accounts](endpoints/managed-accounts.md#refresh-account).

### 2026-07-10

**Added**

- Webhooks are live for partners: register an endpoint to receive signed, real-time event notifications instead of polling. The initial events are `account.created`, `account.refreshed`, `listing.created`, `listing.refreshed`, and `listing.base_price_changed`. See [Webhooks](webhooks/index.md).

### 2026-06-30

**Added**

- List users now supports `filter[email]=<email>` to look up a user by email (exact, case-insensitive). Useful for recovering a user's `id` when you only have the email. See [Users](endpoints/users.md).

**Changed**

- The listing calendar endpoint (`GET /api/v1/listings/{id}/calendar/`) now requires the `reservations:read` scope instead of `listings:read`. Existing tokens that had `listings:read` were granted `reservations:read` so calendar access is uninterrupted; new tokens must request `reservations:read` to read the calendar. See [Calendar](endpoints/calendar.md).

### 2026-06-19

**Added**

- Calendar entries now include `amount` on each pricing factor: the factor's dollar contribution to the modeled price. See [Calendar](endpoints/calendar.md).
- Calendar entries now include `effective-min-price` and `effective-max-price`: the binding price floor and ceiling for each date. See [Calendar](endpoints/calendar.md).

### 2026-06-15

**Added**

- List comp sets now supports `filter[owner]=<user_id>` to narrow results to a single user (useful for full-access partner tokens).
- List comp sets now supports compound documents via `?include=owner,listing`, sideloading the owning user and the anchored Beyond listing (`listing` is `null` for custom comp sets). See [Compsets](endpoints/compsets.md).

**Changed**

- The listing refresh endpoint (`POST /listings/{id}/refresh/`) now also refreshes the listing's details and availability, not just its reservations.

### 2026-06-13

**Changed**

- The optional `X-Request-ID` header now accepts any value made of letters, digits, and `-` `_` `.` `:` separators (up to 128 chars), not just UUIDs. Values outside that set no longer return `400` — the API falls back to a generated request id instead.

### 2026-06-09

**Added**

- Compsets are live: list and retrieve competitive sets for your listings. See [Compsets](endpoints/compsets.md).

### 2026-06-08

**Changed**

- Deleting a user or a managed account now disables its listings.

### 2026-06-05

**Fixed**

- Retrieving min-stay customizations no longer fails for listings that have year-round last-minute (lead-time) min-stay rules configured.