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.
Coming soon — October 2026. This is a contract prototype, not a working API. The V3 Prototype Guide is for implementation planning; its requests cannot be executed today.
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. 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.
For a person automating their own work — a script, a private MCP server, an agent acting for them:
- 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. - The key itself is the Bearer credential — there is no token exchange:
Authorization: Bearer ag_key_…. - 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.
- 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.
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,resource(RFC 8707) and a PKCEcode_challenge(S256). - The user logs in with their Altegio account.
- 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.
- The browser is redirected back to your
redirect_uriwith a short-lived, one-time authorizationcode, yourstateandiss— theissuerfrom the authorization server metadata (RFC 9207). Compareisswith theissueryou recorded before exchanging the code. - Exchange the code at
POST /oauth/tokenwithgrant_type=authorization_codeand the PKCEcode_verifier— the response contains an access token (valid for one hour) and a rotating refresh token (30 days per token, 90 days per family). - 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. Self-onboarding is planned; until it ships, MCP hosts and other OAuth clients use a pre-issued client_id as well. Two mechanisms are planned, and the host picks one. A client_id given as an HTTPS URL pointing to a client metadata document (CIMD) is the main road; 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 only once they ship: 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. Self-registered clients receive base scopes only.
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.
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:
- 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 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.
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}/...; thelocation_idin the route is checked against the token's grants. A foreign Location returns404— 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.
- 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 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;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 (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
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, 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):
{
"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.
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_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 will protect 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). 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.