OAuth

Air supports OAuth 2.0 Authorization Code with PKCE for integrations that need to call the Air Public API on behalf of an Air user.

Use this flow when your app needs user-authorized access across the workspaces that user can access in Air. Access tokens are accepted by https://api.air.inc/v1/... endpoints according to the scopes the user approved, the app's configured access, and the selected workspace for each request.

Choosing Between OAuth and API Keys

Air supports both OAuth and API-key authentication. Choose based on what your integration needs to represent: an individual Air user, or a trusted service connected to one Air workspace.

Use OAuth When

Use OAuth when your integration needs to act on behalf of an Air user.

OAuth is the recommended choice if:

  • Users should connect their own Air account.
  • Air should enforce each user's workspace permissions.
  • Your app is used across multiple Air workspaces or customers.
  • Users should be able to disconnect the app from Air.
  • You need user-level authorization instead of one shared integration identity.

This is usually the right choice for public third-party apps, marketplace integrations, and apps where each user's Air access matters.

Use an API Key When

Use an API key when your integration runs from a trusted backend or automation service for a specific Air workspace.

API keys may be the better choice if:

  • The integration is private or internal.
  • One workspace admin or service account should control access.
  • Users do not need to log into Air individually.
  • Your app only needs access to one Air workspace.
  • Your backend can securely store the API key.

API keys are often simpler for server-to-server workflows, private CMS plugins backed by a server-side integration, and internal automation tools.

Important Security Guidance

Never expose an API key in browser, mobile, desktop, or iframe-based plugin code. Treat an API key like a password.

If your integration has a browser or CMS plugin frontend, use a backend service to store the API key and make Air API requests. The backend should authenticate and authorize the user in your app or CMS before calling Air.

Quick Rule

If Air needs to know which Air user is authorizing access, use OAuth.

If the integration is a private, trusted service for one workspace, and your backend can protect the key, an API key may be simpler.

Base URLs

PurposeProduction URL
Air OAuth consent entrypointhttps://app.air.inc/oauth/consent
OAuth token endpointhttps://auth.air.inc/oauth2/token
Public APIhttps://api.air.inc/v1

Start authorization at Air's consent URL. Direct authorization-server URLs, including https://auth.air.inc/oauth2/authorize, are not a supported integration entrypoint and will fail.

Client Provisioning

OAuth client provisioning is currently handled by Air. To request a client, contact Air Support at [email protected].

Include:

  • App name and support contact.
  • App logo URL, if available.
  • Production redirect URI and any development or staging redirect URIs.
  • Requested Air scopes.
  • Whether your app is a public client, such as a browser or native app, or a confidential server-side client.
  • Whether the app should be available to all Air workspaces or restricted to specific workspaces.

Air will provide the client_id. If your app is provisioned as a confidential client, Air will also provide a client secret. Public clients must not use a client secret.

Redirect URIs must match the provisioned values exactly.

Supported Grant Types

Supported:

  • Authorization Code with PKCE: grant_type=authorization_code
  • Refresh Token: grant_type=refresh_token, when a refresh token is issued to the client

Not supported:

  • Client Credentials
  • Password
  • Device Code
  • Implicit

Authorization Flow

  1. Generate a high-entropy state value.
  2. Generate a PKCE code_verifier.
  3. Derive code_challenge = BASE64URL(SHA256(code_verifier)).
  4. Redirect the user to https://app.air.inc/oauth/consent with the required OAuth parameters.
  5. The user signs in to Air if needed, reviews the Air consent screen, and chooses whether to allow access.
  6. If the user allows access, Air redirects the user back through the OAuth authorization flow.
  7. Your redirect URI receives code and state.
  8. Verify the returned state.
  9. Exchange code and code_verifier for tokens at https://auth.air.inc/oauth2/token.
  10. Use the access token as Authorization: Bearer ... when calling the Air Public API.

If the user denies access, Air redirects to your redirect_uri with:

error=access_denied&state=<state>

Required Authorization Parameters

Send users to https://app.air.inc/oauth/consent with these query parameters:

ParameterRequired value
response_typecode
client_idYour Air-provided OAuth client ID
redirect_uriOne of the exact redirect URIs provisioned for your client
scopeSpace-separated Air scope names, such as assets.read workspace.read
stateHigh-entropy value generated by your app and verified after redirect
code_challengeBase64url-encoded SHA-256 hash of your code_verifier
code_challenge_methodS256

Use Air scope names without a prefix. For example, request assets.read, not public-api/assets.read.

Example authorization URL:

https://app.air.inc/oauth/consent?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fair%2Fcallback&scope=assets.read%20workspace.read&state=STATE_VALUE&code_challenge=CODE_CHALLENGE&code_challenge_method=S256

PKCE and State

PKCE is required. Use S256; plain-text PKCE is not accepted.

The code_verifier should be a cryptographically random string between 43 and 128 characters. Store it server-side or in a secure, same-user session until the callback arrives.

Example JavaScript PKCE helpers:

const base64UrlEncode = (bytes) =>
  btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");

const randomBytes = (length) => {
  const bytes = new Uint8Array(length);
  crypto.getRandomValues(bytes);
  return bytes;
};

export const createState = () => base64UrlEncode(randomBytes(24));

export const createPkcePair = async () => {
  const verifier = base64UrlEncode(randomBytes(32));
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(verifier),
  );
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
};

Use state to protect against CSRF and to recover any app-local return path after the callback. Air returns the value unchanged on success and denial.

Token Exchange

Exchange the authorization code for tokens with application/x-www-form-urlencoded.

Public client example:

curl --request POST "https://auth.air.inc/oauth2/token" \
  --header "Accept: application/json" \
  --header "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "redirect_uri=https://example.com/oauth/air/callback" \
  --data-urlencode "code=AUTHORIZATION_CODE" \
  --data-urlencode "code_verifier=ORIGINAL_CODE_VERIFIER"

Confidential client example, using HTTP Basic client authentication (preferred per RFC 6749 §2.3.1):

curl --request POST "https://auth.air.inc/oauth2/token" \
  --user "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  --header "Accept: application/json" \
  --header "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "redirect_uri=https://example.com/oauth/air/callback" \
  --data-urlencode "code=AUTHORIZATION_CODE" \
  --data-urlencode "code_verifier=ORIGINAL_CODE_VERIFIER"

Confidential clients may alternatively send client_id and client_secret in the form body; do not combine the two methods on the same request.

Successful token responses include an access token and expiration:

{
  "access_token": "eyJ...",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "token_type": "Bearer",
  "scope": "public-api/assets.read public-api/workspace.read"
}

The refresh_token field is present only when the client is configured to receive refresh tokens. Store refresh tokens securely and never expose a confidential client secret in browser or native app code.

Refresh example:

curl --request POST "https://auth.air.inc/oauth2/token" \
  --header "Accept: application/json" \
  --header "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "refresh_token=REFRESH_TOKEN"

For confidential clients, use HTTP Basic client authentication for refresh requests and omit client_id from the form body. Alternatively, include client_id and client_secret in the form body; do not combine the two methods on the same request.

Calling the Public API

Use the access token as a bearer token:

curl "https://api.air.inc/v1/assets?limit=25" \
  --header "Authorization: Bearer ACCESS_TOKEN" \
  --header "x-air-workspace-id: WORKSPACE_ID"

All workspace-scoped Public API routes require x-air-workspace-id. The value must be a UUID for a workspace the user can access.

Do not send x-api-key and Authorization: Bearer ... on the same request. A request with both auth methods is rejected.

Workspace Discovery

OAuth clients can discover workspaces with:

curl "https://api.air.inc/v1/workspaces" \
  --header "Authorization: Bearer ACCESS_TOKEN"

GET /v1/workspaces is bearer-only and requires workspace.read. Do not include x-air-workspace-id for this endpoint.

Response:

{
  "data": [
    {
      "id": "5bd304de-2ea4-435b-a065-69258f340e39",
      "name": "Launch"
    }
  ]
}

Use one of the returned workspace IDs as x-air-workspace-id on subsequent workspace-scoped API requests.

API Key and Bearer Token Rules

Air still supports API-key authentication for existing Public API integrations:

x-api-key: API_KEY
x-air-workspace-id: WORKSPACE_ID

OAuth bearer authentication uses:

Authorization: Bearer ACCESS_TOKEN
x-air-workspace-id: WORKSPACE_ID

Rules:

  • GET /v1/workspaces accepts only OAuth bearer tokens.
  • All other /v1/... Public API routes accept OAuth bearer tokens and, where API-key access is enabled, API keys.
  • Send exactly one auth method per request.
  • OAuth requests are authorized by the approved token scopes, the app's configured access, the selected workspace, and the user's live Air workspace permissions.

Consent and Workspace Permissions

The OAuth approval applies across the current Air workspaces where the app is available to the user. The app can only access data the approving user can already access in Air.

During consent, Air evaluates whether the user has the workspace permissions required by the requested scopes. If permissions are missing, Air shows the affected workspaces and permissions. The user cannot complete authorization until the missing permissions are resolved or the app's workspace availability changes.

For apps limited to specific workspaces, API requests are accepted only for workspaces where the app is available.

Connected-App Disconnects

Users can disconnect an OAuth app from Air account settings.

After revocation:

  • Existing access tokens stop working for API access.
  • Requests fail with 403 and extra.reason: "grant_revoked".
  • To reconnect, send the user through the Air OAuth consent flow again.

If an app is no longer available in Air, requests fail with the same grant_revoked reason. If the token's user has not authorized the app for their Air account, requests fail with extra.reason: "missing_grant".

Scopes

Request the least-privileged scopes your integration needs.

ScopeDescription
assets.readView assets, original files, versions, and discussions.
assets.writeCreate, edit, and delete assets.
boards.readView boards.
boards.writeCreate, edit, delete, and manage board members.
custom_fields.readView custom fields.
custom_fields.writeCreate, edit, delete, and apply custom field values to resources.
tags.readView tags.
tags.writeCreate, edit, delete, and apply tags to resources.
workspace.readView workspace information.
workspace_security.manageManage workspace security settings and audit logs.

Current Public API Scope Requirements

The table below summarizes the OAuth scopes enforced by current Public API routes. Endpoint request and response schemas are documented in the Public API reference.

Endpoint areaRequired scopes
GET /v1/workspacesworkspace.read; OAuth bearer only; no x-air-workspace-id
Read assets and asset versionsassets.read
Create, update, delete, upload, import, or generate CDN links for assetsassets.write
List boards containing an assetassets.read and boards.read
Add or remove tags on an asset versiontags.write
Set custom field values on assets or boardscustom_fields.write
Read boardsboards.read
Create, update, delete boards or manage board guestsboards.write
Add or remove assets on boardsboards.write and assets.write
Read custom fieldscustom_fields.read
Create, update, or delete custom fields and custom field valuescustom_fields.write
Read tagstags.read
Create, update, or delete tagstags.write
Create imports and read import statusassets.write
Get guest rolesworkspace.read
Read audit logsworkspace_security.manage

Rate Limits

Default Public API limits:

Auth modeLimit
API key15 requests per second and 10 concurrent requests per API key
OAuth bearer, per user and app15 requests per second and 10 concurrent requests
OAuth bearer, per workspace and app75 requests per second and 50 concurrent requests

GET /v1/workspaces is not workspace-scoped, so only the OAuth user-and-app limit applies.

Some endpoints may have additional route-specific limits. When a limit is exceeded, the API returns 429.

CORS

Air's Public API supports browser-based OAuth integrations.

Preflight requests:

OPTIONS /v1/assets HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization,content-type,x-air-workspace-id

Successful preflight response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,PUT,POST,DELETE,PATCH
Access-Control-Allow-Headers: authorization,content-type,x-air-workspace-id
Access-Control-Max-Age: 7200
Vary: Origin

Actual Public API responses also include:

Access-Control-Allow-Origin: *
Vary: Origin

Use bearer tokens in the Authorization header. Do not rely on cookies for Public API authentication.

Error Contracts

Consent Errors

Invalid consent requests fail before the user can authorize. Common causes:

  • Missing required OAuth parameter.
  • response_type is not code.
  • code_challenge_method is not S256.
  • Blank client_id, state, or code_challenge.
  • Unknown, blocked, or unavailable client.
  • redirect_uri is not registered for the client.
  • Requested scope is unknown or not allowed for the client.

Consent API errors use Air API error objects. Example:

{
  "statusCode": 400,
  "type": "BAD_REQUEST",
  "message": "Invalid OAuth parameters.",
  "extra": {}
}

If the user lacks required workspace permissions when they try to allow the app:

{
  "statusCode": 403,
  "type": "FORBIDDEN",
  "message": "You cannot authorize this app for all current workspaces.",
  "extra": {
    "reason": "cannot_authorize_all_current_workspaces"
  }
}

Token Endpoint Errors

The token endpoint returns OAuth-standard errors for invalid code exchange, refresh, client authentication, or PKCE verification failures. Treat any non-2xx token response as a failed authorization and restart the Air consent flow when the error cannot be recovered.

Public API Auth Errors

Missing bearer token on OAuth-only routes:

{
  "error": "Unauthorized",
  "message": "Authentication required: provide a valid bearer token"
}

Missing auth on workspace-scoped routes when OAuth is available:

{
  "error": "Unauthorized",
  "message": "Authentication required: provide a valid x-api-key header or Authorization: Bearer token"
}

Missing workspace ID:

{
  "statusCode": 400,
  "type": "INVALID_INPUT",
  "message": "'x-air-workspace-id' is required",
  "error": "'x-air-workspace-id' is required"
}

Invalid workspace ID:

{
  "statusCode": 400,
  "type": "INVALID_INPUT",
  "message": "'x-air-workspace-id' is not a valid uuid",
  "error": "'x-air-workspace-id' is not a valid uuid"
}

Mixed API-key and bearer auth:

{
  "statusCode": 400,
  "type": "INVALID_INPUT",
  "message": "Provide either x-api-key or Authorization: Bearer token, not both",
  "error": "Provide either x-api-key or Authorization: Bearer token, not both"
}

OAuth authorization failures return 403 with a reason:

{
  "statusCode": 403,
  "type": "FORBIDDEN",
  "extra": {
    "reason": "missing_scope"
  }
}

Common OAuth extra.reason values:

ReasonMeaning
invalid_tokenThe token subject or client could not be resolved.
grant_revokedThe user disconnected the app or the app is no longer available in Air.
missing_grantThe token's user has not authorized this app for their Air account. Send the user through the Air OAuth consent flow.
missing_scopeThe token or app authorization does not include a required scope.
permission_deniedThe user does not have access to the requested workspace.
app_not_entitled_to_workspaceThe app is not available for the requested workspace.

Rate limits:

{
  "error": "Too Many Requests",
  "message": "Too Many Requests"
}

Temporary auth or rate limiter outages return 503 with error: "Service Unavailable".

Security Checklist

  • Start every authorization at https://app.air.inc/oauth/consent.
  • Use Authorization Code with PKCE and code_challenge_method=S256.
  • Generate and verify a high-entropy state value.
  • Store refresh tokens securely.
  • Never put client secrets in browser, mobile, desktop, or other public-client code.
  • Do not log authorization codes, access tokens, refresh tokens, or full callback URLs.
  • Request only the scopes your integration needs.
  • Use GET /v1/workspaces to let the user choose the workspace before making workspace-scoped requests.