Skip to content

Exchange a portal session or sign-in code for a scoped user token

POST
/v1/portal/token
curl --request POST \
--url https://api.aerandil.com/v1/portal/token \
--header 'Content-Type: application/json' \
--data '{ "code": "example", "scope": "example", "session": "example" }'

Exchange a human’s verified portal session (WorkOS) — or the authorization code from the AuthKit redirect, which the kernel exchanges with WorkOS itself — for a tenant-scoped bearer token that runs as a User principal (cloud 0006 §10).

The human behind the token is a staff_member when one claims the identity (Ithaca’s desk, 2026-09-09): a staff record whose identity_subject is the provider’s subject — or, on a code exchange, the one active employee carrying the provider’s verified email, bound on that first sign-in through update_staff_member_profile. The token’s principal is then that record, the scope vector follows the employee’s role (a tenant admin keeps the admin grant on top), and the lifetime is a shift (AERANDIL_STAFF_SESSION_TTL). An inactive staff record that claims the identity is refused (403). Otherwise the token runs as the provider subject with the organization role’s vector for the service-token lifetime. An optional scope parameter narrows within the grant but never widens it.

Public endpoint — the caller authenticates with the portal session, not a prior bearer. Returns 501 when no identity provider is configured on the cell.

Media typeapplication/json

Exactly one of session or code: an access token the caller already holds, or the authorization code from the identity provider’s redirect.

object
code

The authorization code from the identity provider’s redirect (WorkOS AuthKit, response_type=code). The kernel exchanges it with the provider using its own API key — a browser surface never holds a provider token — and verifies the resulting session exactly as session.

string
scope

Optional space-separated scope filter. The issued token carries the intersection of this and the human’s role-derived scope vector — a portal can request less than the role grants, never more.

string
session

An access token the caller already holds, validated by the cell’s configured identity provider (WorkOS).

string
Examplegenerated
{
"code": "example",
"scope": "example",
"session": "example"
}

Token minted. scope is the role-derived set, intersected with the scope request parameter when present; staff_member_id names the employee the token runs as, when one claims the identity.

Media typeapplication/json
object
access_token
required

Short-lived HS256 JWT. Send as Authorization: Bearer <token> on subsequent calls.

string
expires_in
required

Token lifetime in seconds. Default 3600 (one hour); deployment-configurable via AERANDIL_TOKEN_TTL_SECS.

integer
>= 1
scope
required

Space-separated list of granted scopes. The set the token actually carries — already narrowed by any scope request filter and the client’s pre-provisioned grants.

string
token_type
required

Always Bearer in v1.

Allowed value: Bearer
principal
required

The User principal the token runs as: the staff_member’s own id when one claims the signed-in identity, else the stable uuid derived from the identity provider’s subject.

string format: uuid
role
required

The role the scope vector was derived from: the employee’s (front_desk, front_office_manager, housekeeper, housekeeping_supervisor, engineer, runner) when a staff record claims the identity, else the organization role (viewer, member, admin).

string
staff_member_id

The staff_member the human resolved to — by identity_subject, or on a code exchange by the one active employee carrying the provider’s verified email (bound on the spot through update_staff_member_profile). null for an operator with no staff record.

string | null format: uuid
Example
{
"token_type": "Bearer"
}

The portal session or code was missing, expired, or rejected.

Media typeapplication/json

Structured error wrapper. Every non-2xx response from any Aerandil endpoint serialises to this shape; the HTTP status carries the failure class (4xx/5xx), the body adds a stable code for programmatic dispatch plus a human-readable message.

object
error
required
object
code
required

Stable, programmatic error code (e.g. unauthorized, forbidden, not_found, invalid_body, invalid_url, precondition_failed, no_secondary_key). Clients dispatch on code, not on the message (which may be localised or contextualised in future versions).

string
instance_id

Request-id correlation handle echoed on the request-id response header — support teams correlate without scraping logs.

string
message
required

Human-readable explanation of the failure.

string
parameters

Per-error structured detail. The shape varies by code (e.g. forbidden_action carries {"action": "..."}; precondition_failed carries {"current_version": N}).

Examplegenerated
{
"error": {
"code": "example",
"instance_id": "example",
"message": "example",
"parameters": "example"
}
}

The staff record that claims this identity is inactive.

Media typeapplication/json

Structured error wrapper. Every non-2xx response from any Aerandil endpoint serialises to this shape; the HTTP status carries the failure class (4xx/5xx), the body adds a stable code for programmatic dispatch plus a human-readable message.

object
error
required
object
code
required

Stable, programmatic error code (e.g. unauthorized, forbidden, not_found, invalid_body, invalid_url, precondition_failed, no_secondary_key). Clients dispatch on code, not on the message (which may be localised or contextualised in future versions).

string
instance_id

Request-id correlation handle echoed on the request-id response header — support teams correlate without scraping logs.

string
message
required

Human-readable explanation of the failure.

string
parameters

Per-error structured detail. The shape varies by code (e.g. forbidden_action carries {"action": "..."}; precondition_failed carries {"current_version": N}).

Examplegenerated
{
"error": {
"code": "example",
"instance_id": "example",
"message": "example",
"parameters": "example"
}
}

More than one staff record claims this identity (ambiguous_staff_identity).

Media typeapplication/json

Structured error wrapper. Every non-2xx response from any Aerandil endpoint serialises to this shape; the HTTP status carries the failure class (4xx/5xx), the body adds a stable code for programmatic dispatch plus a human-readable message.

object
error
required
object
code
required

Stable, programmatic error code (e.g. unauthorized, forbidden, not_found, invalid_body, invalid_url, precondition_failed, no_secondary_key). Clients dispatch on code, not on the message (which may be localised or contextualised in future versions).

string
instance_id

Request-id correlation handle echoed on the request-id response header — support teams correlate without scraping logs.

string
message
required

Human-readable explanation of the failure.

string
parameters

Per-error structured detail. The shape varies by code (e.g. forbidden_action carries {"action": "..."}; precondition_failed carries {"current_version": N}).

Examplegenerated
{
"error": {
"code": "example",
"instance_id": "example",
"message": "example",
"parameters": "example"
}
}

Neither or both of session and code were given.

Media typeapplication/json

Structured error wrapper. Every non-2xx response from any Aerandil endpoint serialises to this shape; the HTTP status carries the failure class (4xx/5xx), the body adds a stable code for programmatic dispatch plus a human-readable message.

object
error
required
object
code
required

Stable, programmatic error code (e.g. unauthorized, forbidden, not_found, invalid_body, invalid_url, precondition_failed, no_secondary_key). Clients dispatch on code, not on the message (which may be localised or contextualised in future versions).

string
instance_id

Request-id correlation handle echoed on the request-id response header — support teams correlate without scraping logs.

string
message
required

Human-readable explanation of the failure.

string
parameters

Per-error structured detail. The shape varies by code (e.g. forbidden_action carries {"action": "..."}; precondition_failed carries {"current_version": N}).

Examplegenerated
{
"error": {
"code": "example",
"instance_id": "example",
"message": "example",
"parameters": "example"
}
}

No identity provider is configured on this cell.

Media typeapplication/json

Structured error wrapper. Every non-2xx response from any Aerandil endpoint serialises to this shape; the HTTP status carries the failure class (4xx/5xx), the body adds a stable code for programmatic dispatch plus a human-readable message.

object
error
required
object
code
required

Stable, programmatic error code (e.g. unauthorized, forbidden, not_found, invalid_body, invalid_url, precondition_failed, no_secondary_key). Clients dispatch on code, not on the message (which may be localised or contextualised in future versions).

string
instance_id

Request-id correlation handle echoed on the request-id response header — support teams correlate without scraping logs.

string
message
required

Human-readable explanation of the failure.

string
parameters

Per-error structured detail. The shape varies by code (e.g. forbidden_action carries {"action": "..."}; precondition_failed carries {"current_version": N}).

Examplegenerated
{
"error": {
"code": "example",
"instance_id": "example",
"message": "example",
"parameters": "example"
}
}