Skip to content

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.

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:

TermMeaning
locationA business location (branch).
team_memberA person on the Location's team who provides services.
serviceA bookable service offered at a Location.
clientA person served by a Location — the person appointments are scheduled for.
appointmentA scheduled visit entry for one Team Member.
visitThe 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:

objectWhat it is
locationA business location and its settings.
service / service_categoryCatalog: a bookable service (simple or package) and its category.
team_member / positionA person on the team and the position dictionary.
clientA person served by a Location.
availability_slotA bookable time slot (computed, read-only).
resource / resource_instanceBookable resources (rooms, equipment) and their instances.
appointmentA scheduled entry for one Team Member.
visit / visit_itemThe billing unit grouping appointments; its service/product lines.
payment / refundA payment applied to a visit and its local reversal.
payment_method / accountPayment configuration dictionaries of the Location.
productA sellable product from the Location catalog (minimal P0 read).
accessible_resourceA Location or chain grant visible on the current token.
team_member_accessSystem-access status and role for a Team Member.
appointment_create_resultPackage booking result with multiple appointments.
listThe 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. 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.

Client Credentials + location_id (marketplace, machine-to-machine)

For marketplace applications acting autonomously (backends, background jobs, agents):

  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 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.
  4. 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:

ScopeFirst-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:createAuthorization 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_accessAuthorization 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.

Authorization Code + PKCE (user-delegated)

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, and a PKCE code_challenge (S256).
  2. The user logs in with their Altegio account.
  3. A consent screen lists the requested scopes; the user grants or declines access.
  4. The browser is redirected back to your redirect_uri with a short-lived, one-time authorization code (and your state).
  5. Exchange the code at POST /oauth/token with grant_type=authorization_code and the PKCE code_verifier — the response contains an access token and a rotating refresh token.
  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 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:

SurfaceEntityIdentifier
Resource API (/clients)the person the business servesclient_id of a /clients resource
OAuth (/oauth/*, /.well-known/*)OAuth client registrationOAuth client_id, e.g. altg_oauth_9c41 or a CIMD URL
Marketplace / Developer Portalthe developer's applicationapplication 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.

Restricted key (single-business integrations)

For a business integrating its own scripts, services, or an owned MCP server:

  1. A business administrator creates a restricted key in the Altegio cabinet, choosing its scopes and expiry. The key value is shown exactly once.
  2. The key itself is the Bearer credential — there is no token exchange: Authorization: Bearer <restricted_key>.
  3. Restricted keys use the same public scope catalog, minus credential-class exclusions such as team_members:manage_access in the first iteration, and can be revoked in the cabinet at any time.

Access model

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}/...; the location_id in the route is checked against the credential. A foreign Location returns 404 — 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.

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, 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; 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 slots) 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.
  • expand[]: related objects are returned as ids by default. Documented relations can be expanded one level deep with expand[]=<relation> (allowlist per endpoint, depth 1). An unknown expand value fails with 422. Expansion never bypasses field-level permissions — see PII below.

Errors

Every error from a resource operation is application/problem+json (RFC 9457 hybrid):

FieldMeaning
typeStable URL identifying the problem type (links to documentation).
titleHuman-readable summary of the problem type.
statusHTTP status code.
detailHuman-readable explanation of this occurrence.
codeStable 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.
instanceRequest 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 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):

CodeHTTPWhen
bad_request400Generic client reject without a more specific registered code (status default for bare 400 and unknown 4xx). Does not mean broken JSON.
malformed_request400Protocol-only: broken JSON / unparseable body / wire-format syntax. Thrown explicitly — never the catch-all for every 400.
invalid_token401Missing, expired, or revoked token.
missing_scope403The token does not carry the required scope.
permission_denied403Scope present, but the subject lacks the business permission.
user_not_verified403Location create blocked: the user account is not verified.
location_creation_forbidden403Location create is not allowed for this subject.
unsupported_token_subject403This credential class cannot perform the operation (e.g. installation token on locations:create).
resource_not_found404Generic 404 fallback: unknown /api/v3/* path, gate/FF hide, or tenant policy without a safe resource-specific code.
{resource}_not_found404Resource absent or hidden by tenant policy (e.g. appointment_not_found, location_not_found).
method_not_allowed405HTTP 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_acceptable406Accept cannot be satisfied.
conflict409Generic 409 fallback when the domain exception has no more specific registered code.
slot_taken409Appointment create lost the race for the requested time slot.
idempotency_key_reused409Same Idempotency-Key with a different request body.
idempotency_request_in_progress409Same key while the first request is still running (Retry-After included).
duplicate_location409Location create conflicts with an existing Location.
last_location_owner409Cannot revoke the last owner of the Location.
invitation_already_pending409A distinct pending invitation already exists.
access_already_active409Team Member already has active system access.
visit_already_settled409Visit is fully paid; adding a line would leave an unpaid remainder.
stale_version412If-Match did not match — the resource was changed by another writer.
unsupported_media_type415Request Content-Type is not supported.
validation_failed422Semantic field validation failed (details in errors[]).
phone_taken422Client phone already belongs to another client of the Location (under clients:write; audited and rate-limited).
exceeds_unpaid422Payment amount exceeds the visit's unpaid remainder.
immutable_after_finance422Country/currency cannot change after financial data exists.
field_not_nullable422Merge-patch tried to null a non-nullable field.
field_immutable422Merge-patch tried to change a read-only field.
role_not_assignable422Requested access role is not assignable in this Location.
invitation_contact_required422Invitation delivery needs a contact and none is available.
invalid_city422city_id is invalid for location create.
unsupported_country422Country derived from city is not supported.
invalid_business_type422business_type_id is invalid.
location_quota_exceeded422Account location quota exceeded.
instrument_not_applicable422Gift card / membership / loyalty instrument is not usable for this visit (unified; does not distinguish unknown vs wrong tenant).
precondition_required428Required If-Match header is missing.
rate_limit_exceeded429Rate limit hit (Retry-After included).
location_creation_rate_limited429Location create rate limit hit.
internal_error500Unexpected server error.
dependency_unavailable503A 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 & concurrency

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_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 protects 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, email, additional_phone — 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.
  • expand[]=client follows 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_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). 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.

Download OpenAPI description
Languages
Servers
Mock server
https://developer.alteg.io/_mock/en/b2b-v3/openapi
Preview resource API contract - endpoint surface is not live yet
https://api.alteg.io/api/v3