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

| Purpose                         | Production URL                            |
| ------------------------------- | ----------------------------------------- |
| Air OAuth consent entrypoint    | `https://app.air.inc/oauth/consent`      |
| OAuth token endpoint            | `https://auth.air.inc/oauth2/token`      |
| Public API                      | `https://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 `help@air.inc`.

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  
01. Generate a high-entropy `state` value.
02. Generate a PKCE `code_verifier`.
03. Derive `code_challenge = BASE64URL(SHA256(code_verifier))`.
04. Redirect the user to `https://app.air.inc/oauth/consent` with the required OAuth parameters.
05. The user signs in to Air if needed, reviews the Air consent screen, and chooses whether to allow access.
06. If the user allows access, Air redirects the user back through the OAuth authorization flow.
07. Your redirect URI receives `code` and `state`.
08. Verify the returned `state`.
09. 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:

```text
error=access_denied&state=<state>
```

## Required Authorization Parameters  
Send users to `https://app.air.inc/oauth/consent` with these query parameters:

| Parameter            | Required value                                |
| ------------------- | ---------------------------------------------- |
| `response_type`    | `code`                                       |
| `client_id`        | Your Air-provided OAuth client ID             |
| `redirect_uri`     | One of the exact redirect URIs provisioned for your client |
| `scope`            | Space-separated Air scope names, such as `assets.read workspace.read` |
| `state`            | High-entropy value generated by your app and verified after redirect |
| `code_challenge`   | Base64url-encoded SHA-256 hash of your `code_verifier` |
| `code_challenge_method` | `S256`                               |

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

Example authorization URL:

```text
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:

```javascript
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:

```shell
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):

```shell
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:

```json
{
  "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:

```shell
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:

```shell
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:

```shell
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:

```json
{
  "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:

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

OAuth bearer authentication uses:

```text
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.

| Scope                         | Description                                   |
| ------------------------------ | --------------------------------------------- |
| `assets.read`                | View assets, original files, versions, and discussions. |
| `assets.write`               | Create, edit, and delete assets.              |
| `boards.read`                | View boards.                                  |
| `boards.write`               | Create, edit, delete, and manage board members. |
| `custom_fields.read`         | View custom fields.                           |
| `custom_fields.write`        | Create, edit, delete, and apply custom field values to resources. |
| `tags.read`                  | View tags.                                    |
| `tags.write`                 | Create, edit, delete, and apply tags to resources. |
| `workspace.read`             | View workspace information.                   |
| `workspace_security.manage`   | Manage 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 area                                   | Required scopes                |
| ----------------------------------------------- | ------------------------------- |
| `GET /v1/workspaces`                           | `workspace.read`; OAuth bearer only; no `x-air-workspace-id` |
| Read assets and asset versions                  | `assets.read`                   |
| Create, update, delete, upload, import, or generate CDN links for assets | `assets.write`                   |
| List boards containing an asset                 | `assets.read` and `boards.read` |
| Add or remove tags on an asset version          | `tags.write`                    |
| Set custom field values on assets or boards     | `custom_fields.write`           |
| Read boards                                     | `boards.read`                   |
| Create, update, delete boards or manage board guests | `boards.write`                 |
| Add or remove assets on boards                  | `boards.write` and `assets.write` |
| Read custom fields                              | `custom_fields.read`            |
| Create, update, or delete custom fields and custom field values | `custom_fields.write`          |
| Read tags                                       | `tags.read`                     |
| Create, update, or delete tags                  | `tags.write`                    |
| Create imports and read import status           | `assets.write`                  |
| Get guest roles                                 | `workspace.read`                |
| Read audit logs                                 | `workspace_security.manage`      |

## Rate Limits  
Default Public API limits:

| Auth mode        | Limit                                  |
| ---------------- | -------------------------------------- |
| API key          | 15 requests per second and 10 concurrent requests per API key |
| OAuth bearer, per user and app | 15 requests per second and 10 concurrent requests |
| OAuth bearer, per workspace and app | 75 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:

```http
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
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:

```http
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:

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

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

```json
{
  "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:

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

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

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

Missing workspace ID:

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

Invalid workspace ID:

```json
{
  "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:

```json
{
  "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:

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

Common OAuth `extra.reason` values:

| Reason                       | Meaning                                             |
| --------------------------- | --------------------------------------------------- |
| `invalid_token`            | The token subject or client could not be resolved.   |
| `grant_revoked`            | The user disconnected the app or the app is no longer available in Air. |
| `missing_grant`            | The token's user has not authorized this app for their Air account. Send the user through the Air OAuth consent flow. |
| `missing_scope`            | The token or app authorization does not include a required scope. |
| `permission_denied`        | The user does not have access to the requested workspace. |
| `app_not_entitled_to_workspace` | The app is not available for the requested workspace. |

Rate limits:

```json
{
  "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.
