Business Management (3.0.0-preview)
⚠️ 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.
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 ispreview— none of them is deployed yet. The October release covers only operations markedavailable, so publishing the full target surface here is not a promise that all of it ships at once.availableis enforced by CI inbiz.erp: anavailableoperation without a registered route fails the OpenAPI conformance gate. - Where a design decision is still pending, the affected element also carries
x-altegio-assumptiondescribing 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. |
V3 accepts exactly one credential shape on every call:
Authorization: Bearer <token>Tokens are opaque strings — never parse or decode them. Access tokens are short-lived (about 15 minutes). There is no public password grant: applications never collect Altegio passwords. Three ways to obtain a credential are supported.
For marketplace applications acting autonomously (backends, background jobs, agents):
- A business installs your application in a Location and approves the scopes your application requests.
- Your backend calls
POST /oauth/tokenwithgrant_type=client_credentials, HTTP Basic client authentication (client_id/client_secret), and the explicitlocation_idyou 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. - The server resolves your installation from the authenticated OAuth client plus that Location, verifies the installation is active, and issues a short-lived opaque access token. The token acts on behalf of the installation — writes are allowed within the scopes the business granted at install time.
- Call the API with
Authorization: Bearer <access_token>. 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 — requesting anything outside it fails with invalid_scope. Restricted keys use the same public catalog, except for credential-class exclusions called out here:
| Scope | First-iteration availability |
|---|---|
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. |
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. |
team_members:manage_access | Authorization Code only in the first iteration. Client Credentials and restricted keys cannot grant, revoke, invite, or change ERP access. |
Scopes are added to a flow or credential class, never removed: adding one later is backward-compatible, so the published lists start conservative.
For applications and agent hosts acting on behalf of a specific Business User:
- Redirect the user's browser to
GET /oauth/authorizewithclient_id,redirect_uri,scope,state, and a PKCEcode_challenge(S256). - The user logs in with their Altegio account.
- A consent screen lists the requested scopes; the user grants or declines access.
- The browser is redirected back to your
redirect_uriwith a short-lived, one-time authorizationcode(and yourstate). - Exchange the code at
POST /oauth/tokenwithgrant_type=authorization_codeand the PKCEcode_verifier— the response contains an access token and a rotating refresh token. - 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 therefore documented here in prose only. The delegated token acts with the permissions of the user who granted it.
OAuth client registration: official and known integrations are pre-registered as OAuth clients. MCP hosts and other OAuth clients can self-onboard through Dynamic Client Registration (POST /oauth/register); a client_id given as an HTTPS URL pointing to a client metadata document (CIMD) is also accepted. 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 still come from an install or from user consent.
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. altg_oauth_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.
For a business integrating its own scripts, services, or an owned MCP server:
- A business administrator creates a restricted key in the Altegio cabinet, choosing its scopes and expiry. The key value is shown exactly once.
- The key itself is the Bearer credential — there is no token exchange:
Authorization: Bearer <restricted_key>. - Restricted keys use the same public scope catalog, minus credential-class exclusions such as
team_members:manage_accessin the first iteration, and can be revoked in the cabinet at any time.
Every request is resolved through the same runtime formula:
effective access = token scopes
∩ scopes granted to the installation / key
∩ business permissions of the token subject
∩ tenant (Location)- A scope opens an endpoint; the subject's business permissions then decide which rows and fields are visible or writable. Having a scope does not bypass the permission system of the business.
- Location binding: resources live under
/api/v3/locations/{location_id}/...; thelocation_idin the route is checked against the credential. A foreign Location returns404— existence is not leaked. - Scopes are credential-class bounded. A credential can use only scopes allowed for its class. An installation (machine-to-machine) token acts with the permissions granted to the installation; a user-delegated token acts with the permissions of that specific Business User; a restricted key acts with the scopes and resource grants set on the key.
- No step-up in this preview. Dangerous operations in this preview are protected by explicit scopes, the subject's business permissions, idempotency, audit, and rate limits. A future human-confirmation flow will be added only with a complete challenge contract.
- Resource shape: a flat JSON object with integer
id, a stringobjecttype 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: passlimitand thenext_cursorvalue from the previous page ascursor. Cursors are opaque, expire, and only work for the credential and filter set that produced them. Page/offset pagination does not exist in V3. - Field naming: all JSON fields, query parameters, and path segments are
snake_case;operationIdvalues arelowerCamelCase. - 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 slots) areHH:MMin the Location's timezone. - Naive datetimes and Unix timestamps never appear.
- Audit instants (
- Money: integer minor units of the Location currency (
1250= one thousand two hundred fifty minor units). Thecurrency(ISO 4217) is server-controlled, read-only, and repeated in money-carrying resources. Monetary field names use the_minorsuffix. Floating-point amounts are rejected. - Partial updates:
PATCHbodies areapplication/merge-patch+json. A missing field means "leave unchanged";nullclears a nullable field; arrays are cleared with an explicit[], nevernull. Clearing a non-nullable field fails with422 field_not_nullable; writing a read-only field fails with422 field_immutable. Unknown request fields are rejected; unknown response fields must be ignored by clients. expand[]: related objects are returned as ids by default. Documented relations can be expanded one level deep withexpand[]=<relation>(allowlist per endpoint, depth 1). An unknown expand value fails with422. Expansion never bypasses field-level permissions — see PII below.
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, 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
V3ProblemCatalogin 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, expired, or revoked token. |
missing_scope | 403 | The token does not carry the required scope. |
permission_denied | 403 | Scope present, but the subject lacks the business permission. |
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, and validation codes such as invalid_datetime.
Example — validation failure (422):
{
"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):
{
"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-Key is required on every POST create and command where a retry could duplicate a side effect (creating appointments, clients, payments; cancel/status commands; refunds).
- 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_progressandRetry-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 protects mutable resources from two writers silently overwriting each other (for example, a human and an agent editing the same appointment):
GETa mutable resource — the response carries anETagheader (creates and updates return the freshETagtoo).- Send
PATCHorDELETEwithIf-Match: <etag>. - On success the response carries the new
ETag. If the resource changed in between, the call fails with412 stale_version— re-read, reapply your change, retry. A missingIf-Matchwhere it is required fails with428 precondition_required.
Client contact fields — phone, email, additional_phone — are sensitive:
- Reading them requires the
clients:read_contactscope and the corresponding business permission of the token subject. - When access is refused, a contact field is returned as
nulland its name is listed inredacted_fieldsof 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. expand[]=clientfollows the same rule: expanding a client on an appointment or visit without contact access returns the client with redacted contact fields. Expansion never widens access.422 phone_takenon create/update is part ofclients:write(no extraclients: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.
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). 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.