# Business Management

> ⚠️ **Preview — this API is not live yet.** Planned release: **October 2026**. The endpoint surface is not deployed; paths and schemas may change before the contract freeze, and try-it and production calls are unavailable.

> **Coming soon — October 2026.** This is a contract prototype, not a working
API. The [V3 Prototype Guide](/en/b2b-v3/quickstart) is for implementation planning;
its requests cannot be executed today.

# Overview
The Business Management API V3 is Altegio's next-generation public REST API.
This document is a **preview of the V3 contract**: it describes the target API surface so
partners and tooling can review it before the endpoints go live.
**Preview status — please read first:**
- This is a
**contract preview, not a live production API**
. The endpoint surface is not

deployed yet; try-it consoles and production calls are unavailable.
- Paths, schemas, and field names
**may change before the contract freeze**
 without

backward-compatibility guarantees.
- Every operation carries an
`x-altegio-status`
. Three values are defined:

`planned`
 — designed, not scheduled for the first production release;

`preview`
 — contract published for review, not deployed;

`available`
 — live in production and covered by the October 2026 release promise.

**Today every operation is `preview`**
 — none of them is deployed yet. The October

release covers only operations marked
`available`
, so publishing the full target

surface here is not a promise that all of it ships at once.

`available`
 is enforced by CI in
`biz.erp`
: an
`available`
 operation without a registered

route fails the OpenAPI conformance gate.
- Where a design decision is still pending, the affected element also carries

`x-altegio-assumption`
 describing the open question.

**Base URL (resource API):** `https://api.alteg.io/api/v3`
All resources are under `/api/v3` and scoped to a Location:
`/api/v3/locations/{location_id}/...`. The `location_id` in the path is verified against
the credential used for the call — a Location the credential cannot access responds with
`404`. OAuth and discovery endpoints live on the authorization host root
`https://api.alteg.io` (no `/api/v3` prefix) — see the Authorization tag.
**Canonical terms.** V3 uses one public name per entity:
| Term | Meaning |
|  --- | --- |
| `location` | A business location (branch). |
| `team_member` | A person on the Location's team who provides services. |
| `service` | A bookable service offered at a Location. |
| `client` | A person served by a Location — the person appointments are scheduled for. |
| `appointment` | A scheduled visit entry for one Team Member. |
| `visit` | The billing unit that groups appointments and carries items and payments. |

**Response format.** V3 is flat resource REST. Single resources are plain
JSON objects with integer `id` and a string `object` type marker. Lists use one predictable
envelope. V3 does **not** use JSON:API (`data.attributes`) or wrapper envelopes such as `{success,data,meta}`
envelopes.
**Object types.** Every resource declares its kind in the `object` field — the full
dictionary of this preview:
| `object` | What it is |
|  --- | --- |
| `location` | A business location and its settings. |
| `service` / `service_category` | Catalog: a bookable service (simple or package) and its category. |
| `team_member` / `position` | A person on the team and the position dictionary. |
| `client` | A person served by a Location. |
| `availability_slot` | A bookable time slot (computed, read-only). |
| `resource` / `resource_instance` | Bookable resources (rooms, equipment) and their instances. |
| `appointment` | A scheduled entry for one Team Member. |
| `visit` / `visit_item` | The billing unit grouping appointments; its service/product lines. |
| `payment` / `refund` | A payment applied to a visit and its local reversal. |
| `payment_method` / `account` | Payment configuration dictionaries of the Location. |
| `product` | A sellable product from the Location catalog (minimal P0 read). |
| `accessible_resource` | A Location or chain grant visible on the current token. |
| `team_member_access` | System-access status and role for a Team Member. |
| `appointment_create_result` | Package booking result with multiple appointments. |
| `list` | The list envelope wrapping any collection response. |

# Authorization
V3 accepts exactly one credential shape on every call:

```
Authorization: Bearer <token>
```
Tokens are **opaque** strings — never parse or decode them. There is **no public password
grant**: applications never collect Altegio passwords. Three kinds of credential exist. The
first two ship in the first iteration; the third is planned for the second delivery.
## API key (your own scripts and agents)
For a person automating their own work — a script, a private MCP server, an agent acting
for them:
1. Any Business User creates an
**API key**
 in the Developer Portal: they pick the Locations

and chains they have access to, the scopes, a name and an expiry (required, a date at most

one year out, read as the end of that day in UTC). The secret
`ag_key_…`
 is shown exactly

once.
2. The key itself is the Bearer credential — there is
**no token exchange**
:

`Authorization: Bearer ag_key_…`
.
3. The key
**acts on behalf of its author**
. Every call checks the key's grants on the

target Location or chain and the author's
*current*
 business permissions there. When the

author loses a permission or a Location, the key loses it too; when the author's account

is removed, the key stops working everywhere. Nothing is copied into the key.
4. The author sees and revokes only their own keys; revocation takes effect on the next

request. To change scopes, issue a new key and revoke the old one — there is no

regeneration.

API keys work only on `/api/v3` routes. Scopes that hand out access to other people
(`team_members:manage_access`, `chain_access:manage`) and other dangerous scopes are never
available to an API key — they are simply absent from the portal.
## Authorization Code + PKCE (user-delegated: agents, MCP hosts, applications)
For applications and agent hosts acting on behalf of a specific Business User:
1. Redirect the user's browser to
`GET /oauth/authorize`
 with
`client_id`
,
`redirect_uri`
,

`scope`
,
`state`
,
`resource`
 (RFC 8707) and a PKCE
`code_challenge`
 (S256).
2. The user logs in with their Altegio account.
3. A
**consent screen**
 shows who is asking and every Location and chain the user has access

to; each one lists the requested scopes that the user's own permissions there allow. There

is no per-Location choice: the user grants the whole set or declines.
4. The browser is redirected back to your
`redirect_uri`
 with a short-lived, one-time

authorization
`code`
, your
`state`
 and
`iss`
 — the
`issuer`
 from the authorization server

metadata (RFC 9207). Compare
`iss`
 with the
`issuer`
 you recorded before exchanging the code.
5. Exchange the code at
`POST /oauth/token`
 with
`grant_type=authorization_code`
 and the

PKCE
`code_verifier`
 — the response contains an access token (valid for one hour) and a

**rotating refresh token**
 (30 days per token, 90 days per family).
6. Refresh with
`grant_type=refresh_token`
. Every use rotates the refresh token; replaying

an already-used refresh token revokes the whole token family.

`GET /oauth/authorize` is a **browser web flow** (HTML login and consent pages), not a JSON
operation — it is documented here in prose only. The delegated token acts with the current
business permissions of the user who granted it, exactly like an API key acts with its
author's. `GET /oauth/accessible-resources` returns the Locations, chains and scopes of the
current token; it answers to API keys as well.
OAuth client registration: official and known integrations are pre-registered as OAuth
clients owned by a developer account. Any other host self-onboards by one of two mechanisms,
and the host picks one. A `client_id` given as an HTTPS URL pointing to a client metadata
document (CIMD, `draft-ietf-oauth-client-id-metadata-document-02`) is the main road: the
authorization server fetches the document on `/oauth/authorize`, `/oauth/token` and
`/oauth/revoke`, checks that its `client_id` equals the URL, takes `client_name` and
`redirect_uris` from it and caches it by the HTTP cache headers. The document must be a JSON
object whose `client_id` equals the URL, with `client_name`, 1 to 10 `redirect_uris` and no
client secret; otherwise the client is treated as unknown. Dynamic Client Registration
(`POST /oauth/register`) stands
beside it for hosts that have no public domain to serve a metadata document from, and for
hosts that have not moved to CIMD yet. Discovery advertises `registration_endpoint` and
`client_id_metadata_document_supported: true`: a client attempts the URL form only when that
flag is present, and some hosts go straight to registration by default.
Registration creates an **OAuth client identity only** — it does not
create a marketplace application, an installation, a business grant, or any access to data;
those come from user consent. A self-registered client may ask for the same scopes as any
client without an application: everything a consent can grant on the requested resource.
**`client_id` means two different things depending on the surface** — the word `client` is
overloaded, so this contract keeps them apart:
| Surface | Entity | Identifier |
|  --- | --- | --- |
| Resource API (`/clients`) | the person the business serves | `client_id` of a `/clients` resource |
| OAuth (`/oauth/*`, `/.well-known/*`) | OAuth client registration | OAuth `client_id`, e.g. `ag_client_9c41` or a CIMD URL |
| Marketplace / Developer Portal | the developer's application | application id — never sent on the wire in V3 |

OAuth `client_id` is the standard field name from RFC 6749 and is not renamed; prose in the
authorization sections always says **OAuth client** when it means the protocol entity.
## Client Credentials + `location_id` (marketplace applications) — planned
For marketplace applications acting autonomously in the Locations that installed them. This
flow is **planned for the second delivery** and is not part of the first iteration; the
contract is published so application developers can prepare:
1. A business installs your application in a Location and approves the scopes your

application requests.
2. Your backend calls
`POST /oauth/token`
 with
`grant_type=client_credentials`
, HTTP Basic

client authentication (
`client_id`
 /
`client_secret`
), and the explicit
`location_id`
 you

want to act in. One token is bound to
**one**
 Location — an application installed in

twenty Locations requests twenty tokens, and there is no account-wide token.
3. The token
**acts on behalf of the installation**
, within the scopes the business

approved at install time. Access tokens last one hour;

this flow has
**no refresh token**
 — request a new access token when the current one

expires.

**Not every scope exists in every credential class.** The `scopes` list published under each
OAuth flow is the set that flow can actually be granted. `GET /oauth/authorize` narrows a
request to that set (RFC 6749 §3.3): scopes outside it are dropped, codes we do not know
(such as `openid`) are ignored, and a request without any known scope asks for every scope
the `resource` can grant. Only a request whose known scopes all fall outside the set is
refused with `invalid_scope`. API keys use the same public catalog with the exclusions
called out here:
| Scope | Availability |
|  --- | --- |
| The 12 first-iteration scopes | Every credential kind. |
| Scopes marked `Planned:` in the flow lists | Listed in discovery like any other scope, except the two rows below; a token may already be issued with them and starts working when the operation ships. |
| `locations:create` | Authorization Code only, planned. Never available to API keys: creating a Location is account-level. |
| `team_members:manage_access`, `chain_access:manage` | Handing out access to other people stays behind the human's own login. Documented here, but granted to nobody in this iteration and left out of `scopes_supported` until moderation for verified partners exists. |

Scopes are added to a flow or credential class, never removed: adding one later is
backward-compatible, so the published lists start conservative.
# Access model
Every request is resolved through the same runtime formula:

```
effective access = token scopes
                 ∩ grants of the credential on the target Location or chain
                 ∩ what the business allows behind that credential
                   (an API key: its author's rights; a delegated token: the rights
                    of the person who consented; an installation token: what the
                    business approved for the application)
                 ∩ tenant (Location)
```
- A
**scope opens an endpoint**
; the rights behind the credential then decide which rows

and fields are visible or writable. Having a scope does not bypass the permission system

of the business, and those rights can change without you: when a business admin narrows

them, the credential narrows with them on the next request.
- **Location binding:**
 resources live under
`/api/v3/locations/{location_id}/...`
; the

`location_id`
 in the route is checked against the token's grants. A foreign Location returns

`404`
 — existence is not leaked.
- **Scopes are credential-class bounded.**
 A credential can use only scopes allowed for its

kind. An API key and a user-delegated token act with the
*live*
 permissions of the

author or the consenting user, read on every request with a cache of at most one minute;

an installation token acts with the permissions granted to the installation. Revocation

is never cached: a revoked token fails on the very next request.
- **No step-up in this preview.**
 Dangerous operations in this preview are protected by

explicit scopes, the subject's business permissions, audit, and rate limits.

A future human-confirmation flow will be added only with a complete challenge contract.

# Conventions
- **Resource shape:**
 a flat JSON object with integer
`id`
, a string
`object`
 type marker,

and domain fields. No envelopes, no prefixes on ids.
- **List shape:**

`{"object": "list", "data": [...], "has_more": false, "next_cursor": null}`
.

Pagination is cursor-based: pass
`limit`
 and the
`next_cursor`
 value from the previous

page as
`cursor`
. Cursors are opaque, expire one hour after the response that issued them

(every page issues a fresh cursor), and only work for the credential, list, ordering and

filter set that produced them. Paging is stable for rows whose ordering columns do not

change between pages; a row whose ordering column was updated meanwhile (for example a

moved appointment) may be skipped or returned twice, and a row created while you page may

be missing from this pass if it sorts before the current position. Page/offset pagination

does not exist in V3.
- **Field naming:**
 all JSON fields, query parameters, and path segments are
`snake_case`
;

`operationId`
 values are
`lowerCamelCase`
.
- **Dates and times:**
 RFC 3339 everywhere.
  - Audit instants (
`created_at`
,
`updated_at`
,
`cancelled_at`
) are UTC:
`2026-07-14T09:20:31Z`
.
  - Appointment times (
`starts_at`
,
`ends_at`
) carry the Location's local UTC offset:

`2026-07-09T11:30:00+01:00`
.
  - Date-only values are
`YYYY-MM-DD`
; wall-clock times (working hours, schedule intervals)

are
`HH:MM`
 in the Location's timezone.
  - Naive datetimes and Unix timestamps never appear.
- **Money:**
 integer
**minor units**
 of the Location currency (
`1250`
 = one thousand two hundred fifty minor units). The

`currency`
 (ISO 4217) is server-controlled, read-only, and repeated in money-carrying

resources. Monetary field names use the
`_minor`
 suffix. Floating-point amounts are rejected.
- **Partial updates:**

`PATCH`
 bodies are
`application/merge-patch+json`
. A missing field

means "leave unchanged";
`null`
 clears a nullable field; arrays are cleared with an

explicit
`[]`
, never
`null`
. Clearing a non-nullable field fails with
`422 field_not_nullable`
; writing a read-only field fails with
`422 field_immutable`
. Unknown

request fields are rejected; unknown response fields must be ignored by clients.
- **`include[]`:**
 relations are returned as ids by default (
`client_id`
,

`team_member_ids`
). Where an operation documents relations,
`include[]=<relation>`
 adds

the related object or array of objects as a new field named after the relation

(allowlist per endpoint, one level deep); it never changes or removes another field, so

the ids stay. A relation the operation does not document or a nested path, including on

an operation without includable relations, fails with
`422 validation_failed`
 and an

`errors[]`
 item with code
`invalid_include`
 for the query parameter
`include[]`
. Fields

added by
`include[]`
 are the only response fields that can be absent; every other

documented field is always present,
`null`
 when there is no value. Including never

bypasses field-level permissions — see PII below.

# Errors
Every error from a resource operation is `application/problem+json` (RFC 9457 hybrid):
| Field | Meaning |
|  --- | --- |
| `type` | Stable URL identifying the problem type (links to documentation). |
| `title` | Human-readable summary of the problem type. |
| `status` | HTTP status code. |
| `detail` | Human-readable explanation of this occurrence. |
| `code` | **Stable machine-readable code** — branch on this, never on text. |
| `errors[]` | Field-level issues: `code`, `source`, `pointer`, `message`. `pointer` is a JSON Pointer for body errors and a parameter name for query/path/header errors. |
| `instance` | Request identifier for support and log correlation. Same value as the `X-Request-Id` header of that response, so quoting either one is enough. |

The OAuth protocol and discovery endpoints are the one exception: `POST /oauth/token`,
`POST /oauth/revoke`, `POST /oauth/register` and `/.well-known/*` return the standard OAuth 2.0
error object (`{"error": "…", "error_description": "…"}`) defined by RFC 6749, 7009 and 7591 —
not `application/problem+json`. The `GET /oauth/authorize` browser flow reports errors on its
own HTML page or as OAuth error parameters on the redirect (`error`, `error_description`,
`state`, `iss`), never as JSON. `GET /oauth/accessible-resources` is the opposite case: despite the path it is an ordinary
Bearer-authed read and uses `application/problem+json` like the rest of the API.
Everything else in this section (stable codes, field-level `errors[]`, request correlation)
describes resource operations. Rate limiting is the one rule both surfaces share: every `429`
carries `Retry-After`.
Two conventions keep this table stable:
- **Promotion.**
 Only public machine-readable codes that clients are expected to branch on

enter this table. Adding a code means editing this table and registering it in the biz.erp

`V3ProblemCatalog`
 in the same cycle — the CI conformance gate keeps both sides in sync.

Internal exceptions are never promoted implicitly: an unregistered code degrades to the

generic status default and nothing internal leaks.
- **Naming.**

`invalid_*`
 means the supplied value does not exist or fails validation

(
`invalid_token`
,
`invalid_city`
,
`invalid_business_type`
);
`unsupported_*`
 means the value

is valid but not supported by the product or policy (
`unsupported_country`
,

`unsupported_media_type`
,
`unsupported_token_subject`
).

Stable codes used by this preview (branch on these; domain codes below are equally stable):
| Code | HTTP | When |
|  --- | --- | --- |
| `bad_request` | 400 | Generic client reject without a more specific registered code (status default for bare 400 and unknown 4xx). Does **not** mean broken JSON. |
| `malformed_request` | 400 | Protocol-only: broken JSON / unparseable body / wire-format syntax. Thrown explicitly — never the catch-all for every 400. |
| `invalid_token` | 401 | Missing, malformed, expired, or revoked credential, or its user account was deleted. v1/v2 authorization (`Bearer <partner>, User <hash>`, `user_hash`) is never accepted here. |
| `missing_scope` | 403 | The token does not carry the required scope. |
| `permission_denied` | 403 | Scope present, but the subject lacks the business permission. |
| `subscription_inactive` | 403 | The Location has no active license (trial or paid). Stop calling this Location and ask its owner to renew the license; retrying or another user does not help. |
| `user_not_verified` | 403 | Location create blocked: the user account is not verified. |
| `location_creation_forbidden` | 403 | Location create is not allowed for this subject. |
| `unsupported_token_subject` | 403 | This credential class cannot perform the operation (e.g. installation token on `locations:create`). |
| `resource_not_found` | 404 | Generic 404 fallback: unknown `/api/v3/*` path, gate/FF hide, or tenant policy without a safe resource-specific code. |
| `{resource}_not_found` | 404 | Resource absent or hidden by tenant policy (e.g. `appointment_not_found`, `location_not_found`). |
| `method_not_allowed` | 405 | HTTP method is not supported on an existing v3 route. Emitted by routing before any operation is selected, so it is deliberately not declared per operation; the body is the same `Problem` shape as every other error. The response carries `Allow` with the methods the route does accept, e.g. `Allow: GET`. |
| `not_acceptable` | 406 | `Accept` cannot be satisfied. |
| `conflict` | 409 | Generic 409 fallback when the domain exception has no more specific registered code. |
| `slot_taken` | 409 | Appointment create lost the race for the requested time slot. |
| `idempotency_key_reused` | 409 | Same `Idempotency-Key` with a different request body. |
| `idempotency_request_in_progress` | 409 | Same key while the first request is still running (`Retry-After` included). |
| `duplicate_location` | 409 | Location create conflicts with an existing Location. |
| `last_location_owner` | 409 | Cannot revoke the last owner of the Location. |
| `invitation_already_pending` | 409 | A distinct pending invitation already exists. |
| `access_already_active` | 409 | Team Member already has active system access. |
| `visit_already_settled` | 409 | Visit is fully paid; adding a line would leave an unpaid remainder. |
| `stale_version` | 412 | `If-Match` did not match — the resource was changed by another writer. |
| `unsupported_media_type` | 415 | Request `Content-Type` is not supported. |
| `validation_failed` | 422 | Semantic field validation failed (details in `errors[]`). |
| `phone_taken` | 422 | Client phone already belongs to another client of the Location (under `clients:write`; audited and rate-limited). |
| `exceeds_unpaid` | 422 | Payment amount exceeds the visit's unpaid remainder. |
| `immutable_after_finance` | 422 | Country/currency cannot change after financial data exists. |
| `field_not_nullable` | 422 | Merge-patch tried to `null` a non-nullable field. |
| `field_immutable` | 422 | Merge-patch tried to change a read-only field. |
| `role_not_assignable` | 422 | Requested access role is not assignable in this Location. |
| `invitation_contact_required` | 422 | Invitation delivery needs a contact and none is available. |
| `invalid_city` | 422 | `city_id` is invalid for location create. |
| `unsupported_country` | 422 | Country derived from city is not supported. |
| `invalid_business_type` | 422 | `business_type_id` is invalid. |
| `location_quota_exceeded` | 422 | Account location quota exceeded. |
| `instrument_not_applicable` | 422 | Gift card / membership / loyalty instrument is not usable for this visit (unified; does not distinguish unknown vs wrong tenant). |
| `precondition_required` | 428 | Required `If-Match` header is missing. |
| `rate_limit_exceeded` | 429 | Rate limit hit (`Retry-After` included). |
| `location_creation_rate_limited` | 429 | Location create rate limit hit. |
| `internal_error` | 500 | Unexpected server error. |
| `dependency_unavailable` | 503 | A required dependency is down; the write was not performed (fail closed). |

Field-level codes inside `errors[]` (not top-level `code`) include `expired_cursor`,
`invalid_cursor`, `foreign_cursor`, `filter_changed`, and validation codes such as `invalid_datetime`.
Example — validation failure (`422`):

```json
{
  "type": "https://developer.alteg.io/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "instance": "urn:altegio:request:req_abc123",
  "code": "validation_failed",
  "errors": [
    {
      "code": "invalid_datetime",
      "source": "body",
      "pointer": "/starts_at",
      "message": "starts_at must be a valid RFC 3339 datetime."
    }
  ]
}
```
Example — stale optimistic-concurrency version (`412`):

```json
{
  "type": "https://developer.alteg.io/problems/stale-version",
  "title": "Resource version is stale",
  "status": 412,
  "detail": "The resource was modified after the version supplied in If-Match. Re-read it and retry.",
  "instance": "urn:altegio:request:req_def456",
  "code": "stale_version"
}
```
Retry guidance depends on the code, not on a generic flag: fix the request on `422`;
re-read the resource on `412`; retry the same `Idempotency-Key` after `Retry-After` on
`409 idempotency_request_in_progress`; back off on `429`/`503`; after a network timeout on
a create or command, retry **only with the same `Idempotency-Key`**.
# Idempotency & concurrency
Both mechanisms below belong to the contract but are **not part of the first iteration**: no
first-iteration operation requires `Idempotency-Key` or `If-Match`, and reads do not return
`ETag` yet. They arrive together with the operations that need them (appointment creation,
payments, concurrent appointment edits) and before contract freeze; operations that already
carry the headers in this document are marked `planned`.
**`Idempotency-Key`** will be required on every `POST` create and command where a retry could
duplicate a side effect (creating appointments, clients, payments; cancel/status commands).
- Generate a unique key per logical request (UUID recommended).
- A retry with the same key and the same body returns the
**original result**
 — including

the case where the first attempt committed but its response was lost.
- The same key with a different body fails with
`409 idempotency_key_reused`
.
- A concurrent duplicate fails with
`409 idempotency_request_in_progress`
 and
`Retry-After`
.
- Keys are retained for
**at least 24 hours**
, scoped to the credential, Location (when

present), and operation. After expiry the same key value may start a
**new**
 logical

request (it is not
`idempotency_key_reused`
).
- If the idempotency store is unavailable, the write is not performed:
`503 dependency_unavailable`
.

**ETag / `If-Match`** will protect mutable resources from two writers silently overwriting
each other (for example, a human and an agent editing the same appointment):
1. `GET`
 a mutable resource — the response carries an
`ETag`
 header (creates and updates

return the fresh
`ETag`
 too).
2. Send
`PATCH`
 or
`DELETE`
 with
`If-Match: <etag>`
.
3. On success the response carries the new
`ETag`
. If the resource changed in between, the

call fails with
`412 stale_version`
 — re-read, reapply your change, retry. A missing

`If-Match`
 where it is required fails with
`428 precondition_required`
.

# PII & redaction
Client contact fields — `phone`, `additional_phone`, `email` — are sensitive:
- Reading them requires the
`clients:read_contact`
 scope
**and**
 the corresponding business

permission of the token subject.
- When access is refused, a contact field is returned as
`null`
 and its name is listed in
`redacted_fields`
 of the same object. It is never replaced with a mask, a placeholder or an empty string, so an integration cannot write a redacted value back.
- A client's
`surname`
 and
`patronymic`
 follow the same rule when the token subject lacks the

business permission to see client surnames; no extra scope is involved.
- The client card
`comment`
 follows the same rule when the token subject lacks the business

permission to see client card comments; it does not depend on contact access.
- `include[]=client`
 follows the same rule: including a client on an appointment or visit

without contact access returns the client with redacted contact fields. Including never

widens access.
- **`422 phone_taken` on create/update**
 is part of
`clients:write`
 (no extra

`clients:read_contact`
). It only signals that the phone is already owned by another

client of this Location — it does not return that client's identity or contact payload.

Phone-bearing writes are audited and share an anti-enumeration rate limit with exact

contact search so the existence signal cannot be bulk-probed.

# HTTP negotiation and caching
Clients should send `Accept: application/json` for successful resource responses and `application/problem+json` for errors; unsupported response media types fail with `406 not_acceptable`. JSON is UTF-8; `Content-Type` may include `charset=utf-8`. Read operations may include `Cache-Control` appropriate to the resource (`no-store` for PII-bearing reads, short `private` or `public` max-age for dictionaries and metadata). `ETag` and `If-Match` are planned (see Idempotency & concurrency); when they ship, mutable resource reads return strong ETags and `If-Match: *` is not accepted on PATCH/DELETE — clients re-read and send the concrete strong ETag.

Version: 3.0.0-preview
License: Altegio API Agreement

## Servers

Preview resource API contract - endpoint surface is not live yet
```
https://api.alteg.io/api/v3
```

## Security

### OAuth2

OAuth 2.0 with **opaque** Bearer tokens (access tokens live one hour). User-facing
applications and agent hosts use Authorization Code + PKCE with a rotating refresh token.
The Client Credentials flow with an explicit `location_id` is for marketplace
applications and is planned for the second delivery. There is no public password grant.
See the Authorization section of the API description for full walkthroughs.
Scopes marked `Planned:` are listed in discovery like any other scope, except
`locations:create` and `team_members:manage_access`; a token may already be issued with
them and starts working when the operation ships. A grant `X:write` includes
`X:read`; `read_contact` and action-scopes (`create`, `capture`, `cancel`, `sell`,
`schedule`, `manage_access`) are never implied. A scope is never refused for arriving without a
companion: a credential may hold `clients:read_contact` alone, it simply opens nothing,
because contact fields live inside the client and appointment reads that their own scopes
gate.

Type: oauth2
Token URL: https://api.alteg.io/oauth/token
Scopes:
- `locations:read`: Planned: view location info and resources
- `locations:write`: Planned: manage location settings
- `services:read`: Planned: view services and service categories
- `services:write`: Planned: manage services and service categories
- `team_members:read`: Planned: view team members, their schedules, service links and positions
- `team_members:write`: Planned: add, update and dismiss team members; manage service links and positions
- `team_members:schedule`: Planned: manage the working schedules of team members
- `clients:read`: Planned: view clients without contact details
- `clients:read_contact`: Planned: view client phone numbers and email
- `clients:write`: Planned: manage clients
- `availability:read`: Planned: view bookable time slots
- `appointments:read`: Planned: view appointments
- `appointments:create`: Planned: create appointments
- `appointments:write`: Planned: edit, cancel and change the status of appointments
- `checkout:read`: Planned: view payment methods and the billing state of a visit
- `checkout:capture`: Planned: record payments
- `checkout:cancel`: Planned: cancel recorded payments locally
- `inventory:sell`: Planned: add products to a visit
- `products:read`: Planned: view the product catalog
- `finance:read`: Planned: view cash registers and financial accounts
- `loyalty:read`: Planned: view loyalty instruments

### ApiKey

An API key created by a Business User in the Developer Portal (`ag_key_…`, shown once,
expires within a year). The key itself is the Bearer credential — no token exchange. It
acts on behalf of its author: grants on the chosen Locations and chains plus the author's
current business permissions, checked on every request. The author revokes it in the
portal; revocation takes effect within seconds. Scopes that hand out access to
people are never available to it.

Type: http
Scheme: bearer

## Download OpenAPI description

 - [Business Management](https://developer.alteg.io/_bundle/en/b2b-v3/openapi.yaml)

## Authorization

 - [GET /.well-known/oauth-authorization-server](https://developer.alteg.io/en/b2b-v3/openapi/authorization/getoauthauthorizationservermetadata.md): Machine-readable OAuth 2.0 authorization server metadata (RFC 8414): endpoint URLs, supported grant types, PKCE methods, and scopes. Agents and MCP hosts use this document to discover the authorizatio
 - [GET /.well-known/oauth-protected-resource/api/v3](https://developer.alteg.io/en/b2b-v3/openapi/authorization/getoauthprotectedresourcemetadata.md): OAuth 2.0 protected resource metadata (RFC 9728) of API V3: the resource identifier, the authorization server that issues tokens for it, and the scopes it accepts. The path follows RFC 9728 §3.1: the
 - [POST /oauth/token](https://developer.alteg.io/en/b2b-v3/openapi/authorization/createoauthtoken.md): OAuth 2.0 token endpoint (RFC 6749). Supports three grants: - `authorization_code` — exchange a code obtained from the browser `GET /oauth/authorize` flow (PKCE `code_verifier` required). Returns
 - [POST /oauth/revoke](https://developer.alteg.io/en/b2b-v3/openapi/authorization/revokeoauthtoken.md): OAuth 2.0 token revocation (RFC 7009). Revokes an access token or a refresh token; revoking a refresh token revokes its whole token family. The endpoint returns `200` even when the token is already in
 - [POST /oauth/register](https://developer.alteg.io/en/b2b-v3/openapi/authorization/registeroauthclient.md): Minimal Dynamic Client Registration (RFC 7591) for MCP hosts and other clients that are not pre-registered. Registration only issues a `client_id` — access to any data still requires the user login/co
 - [GET /oauth/accessible-resources](https://developer.alteg.io/en/b2b-v3/openapi/authorization/listaccessibleresources.md): Returns the resources **this token** is actually granted — not everything the user or application could reach. Each entry carries the scopes granted on that resource, so a client knows which `location
## Locations

 - [GET /reference/cities](https://developer.alteg.io/en/b2b-v3/openapi/locations/listcities.md): Cursor-paginated list. Default ordering: country_id, name, id tie-breaker. Cursors use the `cursor` query parameter, expire after one hour, and are bound to the credential, tenant, list, and filters.
 - [GET /reference/business_types](https://developer.alteg.io/en/b2b-v3/openapi/locations/listbusinesstypes.md): Cursor-paginated list. Default ordering: title, id tie-breaker. Cursors use the `cursor` query parameter, expire after one hour, and are bound to the credential, tenant, list, and filters. Expired, ma
 - [POST /locations](https://developer.alteg.io/en/b2b-v3/openapi/locations/createlocation.md): Creates a standalone Location for the current authenticated user. Authorization is user-delegated only (Authorization Code + PKCE); installation tokens and API keys are rejected. `country_code`, `time
 - [GET /locations/{location_id}](https://developer.alteg.io/en/b2b-v3/openapi/locations/getlocation.md): Returns the Location resource: name, address, contacts, timezone, and currency.
 - [PATCH /locations/{location_id}](https://developer.alteg.io/en/b2b-v3/openapi/locations/updatelocation.md): Partially updates partner-relevant Location settings with a merge-patch body. `timezone`, `country`, and `currency` are read-only and intentionally absent from the update schema — they follow `city_id
## Services

 - [GET /locations/{location_id}/services](https://developer.alteg.io/en/b2b-v3/openapi/services/listservices.md): Returns the services of the Location as a cursor-paginated list. Prices are integer minor units; `duration_seconds` is the default duration, and `team_members[]` lists the Team Members providing the s
 - [POST /locations/{location_id}/services](https://developer.alteg.io/en/b2b-v3/openapi/services/createservice.md): Creates a service in the Location catalog. Link Team Members inline through `team_members[]` — a service without at least one bookable Team Member link does not appear in availability search. Service
 - [GET /locations/{location_id}/services/{service_id}](https://developer.alteg.io/en/b2b-v3/openapi/services/getservice.md): Returns a single service with its category, pricing, duration, Team Member ids, and package components when `type` is `package`. With `include[]=team_members` the response also carries `team_members`:
 - [PATCH /locations/{location_id}/services/{service_id}](https://developer.alteg.io/en/b2b-v3/openapi/services/updateservice.md): Partially updates a service with a merge-patch body. `type` is immutable; for package services, `package.components` replaces the full component list when present. Team Member links are written throug
 - [DELETE /locations/{location_id}/services/{service_id}](https://developer.alteg.io/en/b2b-v3/openapi/services/deleteservice.md): Deletes a service from the catalog.
 - [GET /locations/{location_id}/service_categories](https://developer.alteg.io/en/b2b-v3/openapi/services/listservicecategories.md): Returns the service categories of the Location. Use a category `id` as `category_id` when creating services. Cursor-paginated list. Default ordering: `id` ascending. Cursors use the `cursor` query par
 - [POST /locations/{location_id}/service_categories](https://developer.alteg.io/en/b2b-v3/openapi/services/createservicecategory.md): Creates a service category in the Location catalog. The response is the new category as `listServiceCategories` returns it; the `Location` header holds its URL. - **Name.** Leading and trailing space
 - [PATCH /locations/{location_id}/service_categories/{service_category_id}](https://developer.alteg.io/en/b2b-v3/openapi/services/updateservicecategory.md): Partially updates a service category with a merge-patch body (RFC 7396); `application/json` is accepted with the same semantics. A missing field is left unchanged, so `{}` changes nothing; `null` on `
 - [DELETE /locations/{location_id}/service_categories/{service_category_id}](https://developer.alteg.io/en/b2b-v3/openapi/services/deleteservicecategory.md): Deletes an empty service category from the Location catalog. Fails with `409 conflict`, the reason in `detail`, when the category still holds services of this Location (packages and package components
## Products

 - [GET /locations/{location_id}/products](https://developer.alteg.io/en/b2b-v3/openapi/products/listproducts.md): Returns the minimal product catalog fields available in P0: `id`, `title`, `price_minor`, and `unit`. Inventory levels, balances, cost, and standalone product sales are not part of the preview scope.
## Team Members

 - [GET /locations/{location_id}/team_members](https://developer.alteg.io/en/b2b-v3/openapi/team-members/listteammembers.md): Returns the working Team Members of the Location as a cursor-paginated list; dismissed members are readable by id via `getTeamMember`. The list representation is the same Team Member object as `getTea
 - [POST /locations/{location_id}/team_members](https://developer.alteg.io/en/b2b-v3/openapi/team-members/createteammember.md): Creates a Team Member in the Location. After creating, set the working schedule (`PATCH .../schedule`) and link services (`PATCH .../services`) — otherwise the member does not appear in availability s
 - [GET /locations/{location_id}/team_members/{team_member_id}](https://developer.alteg.io/en/b2b-v3/openapi/team-members/getteammember.md): Returns a single Team Member. Dismissed members are readable; deleted ones answer `404 resource_not_found`.
 - [PATCH /locations/{location_id}/team_members/{team_member_id}](https://developer.alteg.io/en/b2b-v3/openapi/team-members/updateteammember.md): Partially updates the Team Member card with a merge-patch body: name, specialization, position and the paid-seat flag. Service links are written through `updateTeamMemberServices` and dismissal throug
 - [DELETE /locations/{location_id}/team_members/{team_member_id}](https://developer.alteg.io/en/b2b-v3/openapi/team-members/deleteteammember.md): Removes a Team Member from the Location.
 - [GET /locations/{location_id}/team_members/{team_member_id}/schedule](https://developer.alteg.io/en/b2b-v3/openapi/team-members/getteammemberschedule.md): Returns the **configured working schedule** of a Team Member for a date range — the days and working intervals set by the business. This is not availability: free bookable slots come from `GET /locati
 - [PATCH /locations/{location_id}/team_members/{team_member_id}/schedule](https://developer.alteg.io/en/b2b-v3/openapi/team-members/updateteammemberschedule.md): Updates the working schedule as a **merge-patch map keyed by date**: each key is a `YYYY-MM-DD` date, the value sets the working intervals of that day, and `null` clears the day. Dates absent from the
 - [POST /locations/{location_id}/team_members/{team_member_id}/dismiss](https://developer.alteg.io/en/b2b-v3/openapi/team-members/dismissteammember.md): Dismisses a Team Member as a command: the response is the Team Member with `dismissal_date` set and `online_booking_enabled: false`. Dismissal is not a field of the Team Member card — `updateTeamMembe
 - [PATCH /locations/{location_id}/team_members/{team_member_id}/services](https://developer.alteg.io/en/b2b-v3/openapi/team-members/updateteammemberservices.md): Updates which services a Team Member provides and the per-member overrides, as a **merge-patch map keyed by `service_id`**: a value creates or updates the link, `null` unlinks the service, services ab
 - [GET /locations/{location_id}/access_roles](https://developer.alteg.io/en/b2b-v3/openapi/team-members/listlocationaccessroles.md): Returns public role ids and titles only; internal permission slugs are never exposed. Cursor-paginated list. Default ordering: title, role_id tie-breaker. Cursors use the `cursor` query parameter, exp
 - [GET /locations/{location_id}/team_members/{team_member_id}/access](https://developer.alteg.io/en/b2b-v3/openapi/team-members/getteammemberaccess.md): Returns current system-access status for a Team Member: `none`, `invited`, or `active`, plus public `role_id` and pending invitation timestamps. The response carries a strong `ETag` for a subsequent a
 - [PATCH /locations/{location_id}/team_members/{team_member_id}/access](https://developer.alteg.io/en/b2b-v3/openapi/team-members/updateteammemberaccess.md): Merge-patch access settings. Only `role_id` is mutable; unavailable roles fail with `422 role_not_assignable`. Requires `If-Match` with the `ETag` from `getTeamMemberAccess` (or a prior successful wri
 - [DELETE /locations/{location_id}/team_members/{team_member_id}/access](https://developer.alteg.io/en/b2b-v3/openapi/team-members/revoketeammemberaccess.md): Revokes pending or active system access and returns the Team Member to `none`; the Team Member record is not deleted. The last location owner cannot be revoked (`409 last_location_owner`). Requires `I
 - [POST /locations/{location_id}/team_members/{team_member_id}/invitation](https://developer.alteg.io/en/b2b-v3/openapi/team-members/sendteammemberinvitation.md): Sends or resends delivery for the single pending invitation of this Team Member. Delivery contact is set only at Team Member create via write-only `access_invitation.phone` / `access_invitation.email`
## Positions

 - [GET /locations/{location_id}/positions](https://developer.alteg.io/en/b2b-v3/openapi/positions/listpositions.md): Returns the position directory of the Location. Reference a position from a Team Member via `position_id`. Cursor-paginated list. Default ordering: `id` ascending. Cursors use the `cursor` query param
 - [POST /locations/{location_id}/positions](https://developer.alteg.io/en/b2b-v3/openapi/positions/createposition.md): Creates a position.
 - [GET /locations/{location_id}/positions/{position_id}](https://developer.alteg.io/en/b2b-v3/openapi/positions/getposition.md): Returns a single position.
 - [PATCH /locations/{location_id}/positions/{position_id}](https://developer.alteg.io/en/b2b-v3/openapi/positions/updateposition.md): Partially updates a position with a merge-patch body.
 - [DELETE /locations/{location_id}/positions/{position_id}](https://developer.alteg.io/en/b2b-v3/openapi/positions/deleteposition.md): Deletes a position.
## Clients

 - [GET /locations/{location_id}/clients](https://developer.alteg.io/en/b2b-v3/openapi/clients/listclients.md): Searches the client base of the Location. Exact `phone` and `email` contact searches require `clients:read_contact` plus the subject's permission to see client phone numbers and emails in the client l
 - [POST /locations/{location_id}/clients](https://developer.alteg.io/en/b2b-v3/openapi/clients/createclient.md): Creates a client. `name` and `phone` are required. The phone number is deduplicated within the Location: if another (non-deleted) client already owns it, the request fails with `422 phone_taken`. That
 - [GET /locations/{location_id}/clients/{client_id}](https://developer.alteg.io/en/b2b-v3/openapi/clients/getclient.md): Returns a single client. Contact fields (`phone`, `additional_phone`, `email`) require `clients:read_contact` plus the subject's permission to see phone numbers in the client card; `surname` and `patr
 - [PATCH /locations/{location_id}/clients/{client_id}](https://developer.alteg.io/en/b2b-v3/openapi/clients/updateclient.md): Partially updates a client with a merge-patch body. Changing the phone to a number owned by another client fails with `422 phone_taken` — the same `clients:write` signal as on create (audited and rate
 - [DELETE /locations/{location_id}/clients/{client_id}](https://developer.alteg.io/en/b2b-v3/openapi/clients/deleteclient.md): Deletes a client (soft delete). A client with the same phone can be created again afterwards.
## Availability

 - [GET /locations/{location_id}/availability](https://developer.alteg.io/en/b2b-v3/openapi/availability/getavailability.md): Computes available start times for the given services within a date range — optionally narrowed to one Team Member. Slots are derived from configured schedules, existing appointments, and service dura
## Resources

 - [GET /locations/{location_id}/resources](https://developer.alteg.io/en/b2b-v3/openapi/resources/listresources.md): Returns the bookable resources of the Location (rooms, chairs, equipment) with their instances. Pass instance ids as `resource_instance_ids` when creating an appointment for services that require a re
## Appointments

 - [POST /locations/{location_id}/appointments](https://developer.alteg.io/en/b2b-v3/openapi/appointments/createappointment.md): Creates an appointment for a client with a Team Member at a chosen start time. - **A client is optional.** Pass `client_id` of an existing client, an inline `client` object resolved attach-only by nor
 - [GET /locations/{location_id}/appointments](https://developer.alteg.io/en/b2b-v3/openapi/appointments/listappointments.md): Returns the appointments of the Location as a cursor-paginated list, newest window first by start time with a stable tie-breaker. Client contact data inside `include[]=client` uses redacted contact fi
 - [GET /locations/{location_id}/appointments/{appointment_id}](https://developer.alteg.io/en/b2b-v3/openapi/appointments/getappointment.md): Returns a single appointment. `visit_id` links to the billing visit (it is `null` only on appointments created without a client). Re-read the appointment to obtain a fresh `visit_id` — visit grouping
 - [PATCH /locations/{location_id}/appointments/{appointment_id}](https://developer.alteg.io/en/b2b-v3/openapi/appointments/updateappointment.md): Edits or reschedules an appointment with a merge-patch body: start time, Team Member, services, duration, comment. The appointment status is **not** editable here — use the `status` command. Cancellat
 - [POST /locations/{location_id}/appointments/{appointment_id}/cancel](https://developer.alteg.io/en/b2b-v3/openapi/appointments/cancelappointment.md): Cancels an appointment as a command: the response is the appointment with `status: "cancelled"` and `cancelled_at` set. Optionally stores a `reason` and whether to notify the client. Requires `Idempot
 - [POST /locations/{location_id}/appointments/{appointment_id}/status](https://developer.alteg.io/en/b2b-v3/openapi/appointments/setappointmentstatus.md): Moves an appointment through its status lifecycle: `waiting` → `confirmed` → `arrived` / `no_show`. Marking a past visit as `arrived` finalizes it when nothing is left to pay (a fully paid or free vis
## Visits

 - [GET /locations/{location_id}/appointments/{appointment_id}/visit](https://developer.alteg.io/en/b2b-v3/openapi/visits/getvisit.md): Returns the billing state resolved from `appointment_id`: service and products, payment methods applicable to this visit, payments from money and loyalty sources, and `amount_to_pay_minor`. `visit_id`
 - [POST /locations/{location_id}/appointments/{appointment_id}/visit/items](https://developer.alteg.io/en/b2b-v3/openapi/visits/addvisititem.md): Adds one product line to the visit resolved from `appointment_id` — the "came for a haircut, bought shampoo, one receipt" case. Each call appends a single line and never replaces the existing ones, so
## Payments

 - [GET /locations/{location_id}/payment_methods](https://developer.alteg.io/en/b2b-v3/openapi/payments/listpaymentmethods.md): Returns the payment methods configured for the Location - what the business accepts and how each method settles. This is the only source of `payment_method_id` for `createPayment`. It does not say whe
 - [GET /locations/{location_id}/accounts](https://developer.alteg.io/en/b2b-v3/openapi/payments/listaccounts.md): Returns the cash/settlement accounts of the Location. Use an account's `id` as `account_id` in `createPayment` — either as the target of an `account` payment or as an override of a payment method's de
 - [GET /locations/{location_id}/appointments/{appointment_id}/payment_methods/search](https://developer.alteg.io/en/b2b-v3/openapi/payments/searchloyaltyinstrument.md): Looks up a gift card or membership by its number and reports whether it can pay the visit resolved from `appointment_id`. This is for instruments the client does not hold in Altegio: a gift card recei
 - [POST /locations/{location_id}/appointments/{appointment_id}/payments](https://developer.alteg.io/en/b2b-v3/openapi/payments/createpayment.md): Records a money, Client Account, loyalty, gift card, membership, or referral-program payment against the visit resolved from `appointment_id`. This records that payment was received (cash, card termin
 - [POST /locations/{location_id}/appointments/{appointment_id}/payments/{payment_id}/cancel](https://developer.alteg.io/en/b2b-v3/openapi/payments/cancelpayment.md): Locally reverses one payment action in full for the visit resolved from `appointment_id`. Partial reversal is not part of the contract. This is a local cancellation of posting in Altegio, not a paymen
