{"templateId":"api_docs","sharedDataIds":{"apiDocsStore":"api-docs-en/b2b-v3/openapi.yaml","sidebar":"sidebar-en/b2b-v3/sidebars.yaml"},"props":{"definitionId":"en/b2b-v3/openapi.yaml","settings":{"baseUrlPath":"/en/b2b-v3/openapi"},"disableAutoScroll":true,"seo":{"title":"Business Management","description":"Official Altegio API documentation for integrating online booking, CRM, scheduling, POS, and business management features into your applications.","siteUrl":"https://developer.alteg.io","lang":"en","keywords":["Altegio API","REST API","online booking API","appointment scheduling API","business management API","CRM integration","POS API"],"image":"/assets/og-image.5c9c342f49e4dd72b96f7a0faf29bb71fab6ff06bbf97cf4b230d6fcc0abcdb3.e746d8a7.jpg","meta":[{"name":"author","content":"Altegio"},{"name":"twitter:card","content":"summary_large_image"},{"name":"twitter:site","content":"@altegio"}]},"dynamicMarkdocComponents":[],"metadata":{"type":"openapi","title":"Business Management","version":"3.0.0-preview","description":"> ⚠️ **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.\n\n# Overview\n\nThe Business Management API V3 is Altegio's next-generation public REST API.\nThis document is a **preview of the V3 contract**: it describes the target API surface so\npartners and tooling can review it before the endpoints go live.\n\n**Preview status — please read first:**\n\n- This is a **contract preview, not a live production API**. The endpoint surface is not\n  deployed yet; try-it consoles and production calls are unavailable.\n- Paths, schemas, and field names **may change before the contract freeze** without\n  backward-compatibility guarantees.\n- Every operation carries an `x-altegio-status`. Three values are defined:\n  `planned` — designed, not scheduled for the first production release;\n  `preview` — contract published for review, not deployed;\n  `available` — live in production and covered by the October 2026 release promise.\n  **Today every operation is `preview`** — none of them is deployed yet. The October\n  release covers only operations marked `available`, so publishing the full target\n  surface here is not a promise that all of it ships at once.\n  `available` is enforced by CI in `biz.erp`: an `available` operation without a registered\n  route fails the OpenAPI conformance gate.\n- Where a design decision is still pending, the affected element also carries\n  `x-altegio-assumption` describing the open question.\n\n**Base URL (resource API):** `https://api.alteg.io/api/v3`\n\nAll resources are under `/api/v3` and scoped to a Location:\n`/api/v3/locations/{location_id}/...`. The `location_id` in the path is verified against\nthe credential used for the call — a Location the credential cannot access responds with\n`404`. OAuth and discovery endpoints live on the authorization host root\n`https://api.alteg.io` (no `/api/v3` prefix) — see the Authorization tag.\n\n**Canonical terms.** V3 uses one public name per entity:\n\n| Term | Meaning |\n|---|---|\n| `location` | A business location (branch). |\n| `team_member` | A person on the Location's team who provides services. |\n| `service` | A bookable service offered at a Location. |\n| `client` | A person served by a Location — the person appointments are scheduled for. |\n| `appointment` | A scheduled visit entry for one Team Member. |\n| `visit` | The billing unit that groups appointments and carries items and payments. |\n\n**Response format.** V3 is flat resource REST. Single resources are plain\nJSON objects with integer `id` and a string `object` type marker. Lists use one predictable\nenvelope. V3 does **not** use JSON:API (`data.attributes`) or wrapper envelopes such as `{success,data,meta}`\nenvelopes.\n\n**Object types.** Every resource declares its kind in the `object` field — the full\ndictionary of this preview:\n\n| `object` | What it is |\n|---|---|\n| `location` | A business location and its settings. |\n| `service` / `service_category` | Catalog: a bookable service (simple or package) and its category. |\n| `team_member` / `position` | A person on the team and the position dictionary. |\n| `client` | A person served by a Location. |\n| `availability_slot` | A bookable time slot (computed, read-only). |\n| `resource` / `resource_instance` | Bookable resources (rooms, equipment) and their instances. |\n| `appointment` | A scheduled entry for one Team Member. |\n| `visit` / `visit_item` | The billing unit grouping appointments; its service/product lines. |\n| `payment` / `refund` | A payment applied to a visit and its local reversal. |\n| `payment_method` / `account` | Payment configuration dictionaries of the Location. |\n| `product` | A sellable product from the Location catalog (minimal P0 read). |\n| `accessible_resource` | A Location or chain grant visible on the current token. |\n| `team_member_access` | System-access status and role for a Team Member. |\n| `appointment_create_result` | Package booking result with multiple appointments. |\n| `list` | The list envelope wrapping any collection response. |\n\n# Authorization\n\nV3 accepts exactly one credential shape on every call:\n\n```\nAuthorization: Bearer <token>\n```\n\nTokens are **opaque** strings — never parse or decode them. Access tokens are short-lived\n(about 15 minutes). There is **no public password grant**: applications never collect\nAltegio passwords. Three ways to obtain a credential are supported.\n\n## Client Credentials + `location_id` (marketplace, machine-to-machine)\n\nFor marketplace applications acting autonomously (backends, background jobs, agents):\n\n1. A business installs your application in a Location and approves the scopes your\n   application requests.\n2. Your backend calls `POST /oauth/token` with `grant_type=client_credentials`, HTTP Basic\n   client authentication (`client_id` / `client_secret`), and the explicit `location_id` you\n   want to act in. One token is bound to **one** Location — an application installed in\n   twenty Locations requests twenty tokens, and there is no account-wide token.\n3. The server resolves your installation from the authenticated OAuth client plus that\n   Location, verifies the installation is active, and issues a short-lived opaque access\n   token. The token **acts on behalf of the installation** — writes are allowed within\n   the scopes the business granted at install time.\n4. Call the API with `Authorization: Bearer <access_token>`. This flow has **no refresh\n   token** — request a new access token when the current one expires.\n\n**Not every scope exists in every credential class.** The `scopes` list published under\neach OAuth flow is the set that flow can actually be granted — requesting anything outside\nit fails with `invalid_scope`. Restricted keys use the same public catalog, except for\ncredential-class exclusions called out here:\n\n| Scope | First-iteration availability |\n|---|---|\n| `chain_*` (4 scopes) | Authorization Code and restricted keys may hold chain grants. A chain-wide marketplace installation is not part of the first iteration, so Client Credentials cannot receive them. |\n| `locations:create` | Authorization Code only. Creating a Location is account-level; Client Credentials always act inside one existing Location, and restricted keys cannot create new Locations. |\n| `team_members:manage_access` | Authorization Code only in the first iteration. Client Credentials and restricted keys cannot grant, revoke, invite, or change ERP access. |\n\nScopes are added to a flow or credential class, never removed: adding one later is\nbackward-compatible, so the published lists start conservative.\n\n## Authorization Code + PKCE (user-delegated)\n\nFor applications and agent hosts acting on behalf of a specific Business User:\n\n1. Redirect the user's browser to `GET /oauth/authorize` with `client_id`, `redirect_uri`,\n   `scope`, `state`, and a PKCE `code_challenge` (S256).\n2. The user logs in with their Altegio account.\n3. A **consent screen** lists the requested scopes; the user grants or declines access.\n4. The browser is redirected back to your `redirect_uri` with a short-lived, one-time\n   authorization `code` (and your `state`).\n5. Exchange the code at `POST /oauth/token` with `grant_type=authorization_code` and the\n   PKCE `code_verifier` — the response contains an access token and a **rotating refresh\n   token**.\n6. Refresh with `grant_type=refresh_token`. Every use rotates the refresh token; replaying\n   an already-used refresh token revokes the whole token family.\n\n`GET /oauth/authorize` is a **browser web flow** (HTML login and consent pages), not a JSON\noperation — it is therefore documented here in prose only. The delegated token acts with\nthe permissions of the user who granted it.\n\nOAuth client registration: official and known integrations are pre-registered as OAuth\nclients. MCP hosts and other OAuth clients can self-onboard through Dynamic Client\nRegistration (`POST /oauth/register`); a `client_id` given as an HTTPS URL pointing to a\nclient metadata document (CIMD) is also accepted. Registration creates an **OAuth client\nidentity only** — it does not create a marketplace application, an installation, a business\ngrant, or any access to data; those still come from an install or from user consent.\n\n**`client_id` means two different things depending on the surface** — the word `client` is\noverloaded, so this contract keeps them apart:\n\n| Surface | Entity | Identifier |\n|---|---|---|\n| Resource API (`/clients`) | the person the business serves | `client_id` of a `/clients` resource |\n| OAuth (`/oauth/*`, `/.well-known/*`) | OAuth client registration | OAuth `client_id`, e.g. `altg_oauth_9c41` or a CIMD URL |\n| Marketplace / Developer Portal | the developer's application | application id — never sent on the wire in V3 |\n\nOAuth `client_id` is the standard field name from RFC 6749 and is not renamed; prose in the\nauthorization sections always says **OAuth client** when it means the protocol entity.\n\n## Restricted key (single-business integrations)\n\nFor a business integrating its own scripts, services, or an owned MCP server:\n\n1. A business administrator creates a **restricted key** in the Altegio cabinet, choosing\n   its scopes and expiry. The key value is shown exactly once.\n2. The key itself is the Bearer credential — there is **no token exchange**:\n   `Authorization: Bearer <restricted_key>`.\n3. Restricted keys use the same public scope catalog, minus credential-class exclusions\n   such as `team_members:manage_access` in the first iteration, and can be revoked in the\n   cabinet at any time.\n\n# Access model\n\nEvery request is resolved through the same runtime formula:\n\n```\neffective access = token scopes\n                 ∩ scopes granted to the installation / key\n                 ∩ business permissions of the token subject\n                 ∩ tenant (Location)\n```\n\n- A **scope opens an endpoint**; the subject's business permissions then decide which rows and\n  fields are visible or writable. Having a scope does not bypass the permission system of\n  the business.\n- **Location binding:** resources live under `/api/v3/locations/{location_id}/...`; the\n  `location_id` in the route is checked against the credential. A foreign Location returns\n  `404` — existence is not leaked.\n- **Scopes are credential-class bounded.** A credential can use only scopes allowed for its\n  class. An installation (machine-to-machine) token acts with the permissions granted to the\n  installation; a user-delegated token acts with the permissions of that specific Business\n  User; a restricted key acts with the scopes and resource grants set on the key.\n- **No step-up in this preview.** Dangerous operations in this preview are protected by\n  explicit scopes, the subject's business permissions, idempotency, audit, and rate limits.\n  A future human-confirmation flow will be added only with a complete challenge contract.\n\n# Conventions\n\n- **Resource shape:** a flat JSON object with integer `id`, a string `object` type marker,\n  and domain fields. No envelopes, no prefixes on ids.\n- **List shape:** `{\"object\": \"list\", \"data\": [...], \"has_more\": false, \"next_cursor\": null}`.\n  Pagination is cursor-based: pass `limit` and the `next_cursor` value from the previous\n  page as `cursor`. Cursors are opaque, expire, and only work for the credential and filter\n  set that produced them. Page/offset pagination does not exist in V3.\n- **Field naming:** all JSON fields, query parameters, and path segments are `snake_case`;\n  `operationId` values are `lowerCamelCase`.\n- **Dates and times:** RFC 3339 everywhere.\n  - Audit instants (`created_at`, `updated_at`, `cancelled_at`) are UTC: `2026-07-14T09:20:31Z`.\n  - Appointment times (`starts_at`, `ends_at`) carry the Location's local UTC offset:\n    `2026-07-09T11:30:00+01:00`.\n  - Date-only values are `YYYY-MM-DD`; wall-clock times (working hours, schedule slots)\n    are `HH:MM` in the Location's timezone.\n  - Naive datetimes and Unix timestamps never appear.\n- **Money:** integer **minor units** of the Location currency (`1250` = one thousand two hundred fifty minor units). The\n  `currency` (ISO 4217) is server-controlled, read-only, and repeated in money-carrying\n  resources. Monetary field names use the `_minor` suffix. Floating-point amounts are rejected.\n- **Partial updates:** `PATCH` bodies are `application/merge-patch+json`. A missing field\n  means \"leave unchanged\"; `null` clears a nullable field; arrays are cleared with an\n  explicit `[]`, never `null`. Clearing a non-nullable field fails with `422\n  field_not_nullable`; writing a read-only field fails with `422 field_immutable`. Unknown\n  request fields are rejected; unknown response fields must be ignored by clients.\n- **`expand[]`:** related objects are returned as ids by default. Documented relations can\n  be expanded one level deep with `expand[]=<relation>` (allowlist per endpoint, depth 1).\n  An unknown expand value fails with `422`. Expansion never bypasses field-level\n  permissions — see PII below.\n\n# Errors\n\nEvery error from a resource operation is `application/problem+json` (RFC 9457 hybrid):\n\n| Field | Meaning |\n|---|---|\n| `type` | Stable URL identifying the problem type (links to documentation). |\n| `title` | Human-readable summary of the problem type. |\n| `status` | HTTP status code. |\n| `detail` | Human-readable explanation of this occurrence. |\n| `code` | **Stable machine-readable code** — branch on this, never on text. |\n| `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. |\n| `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. |\n\nThe OAuth protocol and discovery endpoints are the one exception: `POST /oauth/token`,\n`POST /oauth/revoke`, `POST /oauth/register` and `/.well-known/*` return the standard OAuth 2.0\nerror object (`{\"error\": \"…\", \"error_description\": \"…\"}`) defined by RFC 6749, 7009 and 7591 —\nnot `application/problem+json`. The `GET /oauth/authorize` browser flow reports errors on its\nown HTML page or as OAuth error parameters on the redirect, never as JSON. `GET\n/oauth/accessible-resources` is the opposite case: despite the path it is an ordinary\nBearer-authed read and uses `application/problem+json` like the rest of the API.\n\nEverything else in this section (stable codes, field-level `errors[]`, request correlation)\ndescribes resource operations. Rate limiting is the one rule both surfaces share: every `429`\ncarries `Retry-After`.\n\nTwo conventions keep this table stable:\n\n- **Promotion.** Only public machine-readable codes that clients are expected to branch on\n  enter this table. Adding a code means editing this table and registering it in the biz.erp\n  `V3ProblemCatalog` in the same cycle — the CI conformance gate keeps both sides in sync.\n  Internal exceptions are never promoted implicitly: an unregistered code degrades to the\n  generic status default and nothing internal leaks.\n- **Naming.** `invalid_*` means the supplied value does not exist or fails validation\n  (`invalid_token`, `invalid_city`, `invalid_business_type`); `unsupported_*` means the value\n  is valid but not supported by the product or policy (`unsupported_country`,\n  `unsupported_media_type`, `unsupported_token_subject`).\n\nStable codes used by this preview (branch on these; domain codes below are equally stable):\n\n| Code | HTTP | When |\n|---|---:|---|\n| `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. |\n| `malformed_request` | 400 | Protocol-only: broken JSON / unparseable body / wire-format syntax. Thrown explicitly — never the catch-all for every 400. |\n| `invalid_token` | 401 | Missing, expired, or revoked token. |\n| `missing_scope` | 403 | The token does not carry the required scope. |\n| `permission_denied` | 403 | Scope present, but the subject lacks the business permission. |\n| `user_not_verified` | 403 | Location create blocked: the user account is not verified. |\n| `location_creation_forbidden` | 403 | Location create is not allowed for this subject. |\n| `unsupported_token_subject` | 403 | This credential class cannot perform the operation (e.g. installation token on `locations:create`). |\n| `resource_not_found` | 404 | Generic 404 fallback: unknown `/api/v3/*` path, gate/FF hide, or tenant policy without a safe resource-specific code. |\n| `{resource}_not_found` | 404 | Resource absent or hidden by tenant policy (e.g. `appointment_not_found`, `location_not_found`). |\n| `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`. |\n| `not_acceptable` | 406 | `Accept` cannot be satisfied. |\n| `conflict` | 409 | Generic 409 fallback when the domain exception has no more specific registered code. |\n| `slot_taken` | 409 | Appointment create lost the race for the requested time slot. |\n| `idempotency_key_reused` | 409 | Same `Idempotency-Key` with a different request body. |\n| `idempotency_request_in_progress` | 409 | Same key while the first request is still running (`Retry-After` included). |\n| `duplicate_location` | 409 | Location create conflicts with an existing Location. |\n| `last_location_owner` | 409 | Cannot revoke the last owner of the Location. |\n| `invitation_already_pending` | 409 | A distinct pending invitation already exists. |\n| `access_already_active` | 409 | Team Member already has active system access. |\n| `visit_already_settled` | 409 | Visit is fully paid; adding a line would leave an unpaid remainder. |\n| `stale_version` | 412 | `If-Match` did not match — the resource was changed by another writer. |\n| `unsupported_media_type` | 415 | Request `Content-Type` is not supported. |\n| `validation_failed` | 422 | Semantic field validation failed (details in `errors[]`). |\n| `phone_taken` | 422 | Client phone already belongs to another client of the Location (under `clients:write`; audited and rate-limited). |\n| `exceeds_unpaid` | 422 | Payment amount exceeds the visit's unpaid remainder. |\n| `immutable_after_finance` | 422 | Country/currency cannot change after financial data exists. |\n| `field_not_nullable` | 422 | Merge-patch tried to `null` a non-nullable field. |\n| `field_immutable` | 422 | Merge-patch tried to change a read-only field. |\n| `role_not_assignable` | 422 | Requested access role is not assignable in this Location. |\n| `invitation_contact_required` | 422 | Invitation delivery needs a contact and none is available. |\n| `invalid_city` | 422 | `city_id` is invalid for location create. |\n| `unsupported_country` | 422 | Country derived from city is not supported. |\n| `invalid_business_type` | 422 | `business_type_id` is invalid. |\n| `location_quota_exceeded` | 422 | Account location quota exceeded. |\n| `instrument_not_applicable` | 422 | Gift card / membership / loyalty instrument is not usable for this visit (unified; does not distinguish unknown vs wrong tenant). |\n| `precondition_required` | 428 | Required `If-Match` header is missing. |\n| `rate_limit_exceeded` | 429 | Rate limit hit (`Retry-After` included). |\n| `location_creation_rate_limited` | 429 | Location create rate limit hit. |\n| `internal_error` | 500 | Unexpected server error. |\n| `dependency_unavailable` | 503 | A required dependency is down; the write was not performed (fail closed). |\n\nField-level codes inside `errors[]` (not top-level `code`) include `expired_cursor`,\n`invalid_cursor`, `foreign_cursor`, and validation codes such as `invalid_datetime`.\n\nExample — validation failure (`422`):\n\n```json\n{\n  \"type\": \"https://developer.alteg.io/problems/validation-error\",\n  \"title\": \"Request validation failed\",\n  \"status\": 422,\n  \"detail\": \"One or more fields are invalid.\",\n  \"instance\": \"urn:altegio:request:req_abc123\",\n  \"code\": \"validation_failed\",\n  \"errors\": [\n    {\n      \"code\": \"invalid_datetime\",\n      \"source\": \"body\",\n      \"pointer\": \"/starts_at\",\n      \"message\": \"starts_at must be a valid RFC 3339 datetime.\"\n    }\n  ]\n}\n```\n\nExample — stale optimistic-concurrency version (`412`):\n\n```json\n{\n  \"type\": \"https://developer.alteg.io/problems/stale-version\",\n  \"title\": \"Resource version is stale\",\n  \"status\": 412,\n  \"detail\": \"The resource was modified after the version supplied in If-Match. Re-read it and retry.\",\n  \"instance\": \"urn:altegio:request:req_def456\",\n  \"code\": \"stale_version\"\n}\n```\n\nRetry guidance depends on the code, not on a generic flag: fix the request on `422`;\nre-read the resource on `412`; retry the same `Idempotency-Key` after `Retry-After` on\n`409 idempotency_request_in_progress`; back off on `429`/`503`; after a network timeout on\na create or command, retry **only with the same `Idempotency-Key`**.\n\n# Idempotency & concurrency\n\n**`Idempotency-Key`** is required on every `POST` create and command where a retry could\nduplicate a side effect (creating appointments, clients, payments; cancel/status commands;\nrefunds).\n\n- Generate a unique key per logical request (UUID recommended).\n- A retry with the same key and the same body returns the **original result** — including\n  the case where the first attempt committed but its response was lost.\n- The same key with a different body fails with `409 idempotency_key_reused`.\n- A concurrent duplicate fails with `409 idempotency_request_in_progress` and `Retry-After`.\n- Keys are retained for **at least 24 hours**, scoped to the credential, Location (when\n  present), and operation. After expiry the same key value may start a **new** logical\n  request (it is not `idempotency_key_reused`).\n- If the idempotency store is unavailable, the write is not performed: `503\n  dependency_unavailable`.\n\n**ETag / `If-Match`** protects mutable resources from two writers silently overwriting each\nother (for example, a human and an agent editing the same appointment):\n\n1. `GET` a mutable resource — the response carries an `ETag` header (creates and updates\n   return the fresh `ETag` too).\n2. Send `PATCH` or `DELETE` with `If-Match: <etag>`.\n3. On success the response carries the new `ETag`. If the resource changed in between, the\n   call fails with `412 stale_version` — re-read, reapply your change, retry. A missing\n   `If-Match` where it is required fails with `428 precondition_required`.\n\n# PII & redaction\n\nClient contact fields — `phone`, `email`, `additional_phone` — are sensitive:\n\n- Reading them requires the `clients:read_contact` scope **and** the corresponding business\n  permission of the token subject.\n- 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.\n- `expand[]=client` follows the same rule: expanding a client on an appointment or visit\n  without contact access returns the client with redacted contact fields. Expansion never\n  widens access.\n- **`422 phone_taken` on create/update** is part of `clients:write` (no extra\n  `clients:read_contact`). It only signals that the phone is already owned by another\n  client of this Location — it does not return that client's identity or contact payload.\n  Phone-bearing writes are audited and share an anti-enumeration rate limit with exact\n  contact search so the existence signal cannot be bulk-probed.\n\n# HTTP negotiation and caching\n\nClients 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). Mutable resource reads return strong ETags suitable for `If-Match`; computed/list/read-model responses may use weak ETags only for cache validation and not for write preconditions. `If-Match: *` is not accepted on PATCH/DELETE in V3 preview; clients must re-read and send the concrete strong ETag.\n"},"compilationErrors":[],"markdown":{"partials":{},"variables":{"rbac":{"teams":["anonymous"]},"user":{},"remoteAddr":{"hostname":"developer.alteg.io","port":4000,"ipAddress":"216.73.217.154"},"lang":"default_locale","env":{"PUBLIC_REDOCLY_BRANCH_NAME":"master"}}},"pagePropGetterError":{"message":"","name":""}},"slug":"/en/b2b-v3/openapi","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}