# Air API SDK

The `@air/api-sdk` package is a typed TypeScript/JavaScript client for the Air API. It handles authentication, pagination, retries, error handling, and file uploads so you can focus on building your integration instead of managing low-level HTTP details.

## Installation

Requires Node.js >= 18 (or Bun).

```shell
npm install @air/api-sdk
# or
yarn add @air/api-sdk
# or
bun add @air/api-sdk
```

## Quick Start

```ts
import { AirApi } from "@air/api-sdk";

const air = new AirApi({
  apiKey: "your-api-key",
  workspaceId: "your-workspace-id",
});

// List boards in your workspace
const page = await air.boards.list();
console.log(page.data);
```

For details on obtaining your API key and workspace ID, see the [Getting Started](https://developer.air.inc/docs/getting-started) guide.

## Configuration

### Constructor options

```ts
const air = new AirApi({
  apiKey: "your-api-key",            // or AIR_API_KEY env var
  accessToken: "your-oauth-token",   // or AIR_ACCESS_TOKEN env var
  workspaceId: "your-workspace-id",  // or AIR_WORKSPACE_ID env var
  baseURL: "https://api.air.inc/v1", // default
  maxRetries: 3,                     // default, with exponential backoff
  timeout: 60_000,                   // default, in milliseconds
  defaultHeaders: {                  // optional, sent with every request
    "user-agent": "my-app/1.0",
    "x-custom-header": "value",
  },
});
```

| Option           | Type     | Default                          | Description                                     |
|------------------|----------|----------------------------------|-------------------------------------------------|
| `apiKey`         | `string` | `AIR_API_KEY` env var           | API key for authentication                       |
| `accessToken`    | `string` | `AIR_ACCESS_TOKEN` env var      | OAuth 2.0 bearer token                          |
| `workspaceId`    | `string` | `AIR_WORKSPACE_ID` env var      | Target workspace ID                             |
| `baseURL`        | `string` | `https://api.air.inc/v1`        | API base URL                                   |
| `maxRetries`     | `number` | `3`                              | Max automatic retries for retryable errors      |
| `timeout`        | `number` | `60000`                          | Request timeout in milliseconds                 |
| `defaultHeaders` | `object` | `undefined`                      | Headers sent with every request                |

See [Authentication](https://developer.air.inc/page/air-api-sdk#authentication) below for how `apiKey`, `accessToken`, and `workspaceId` interact.

### Authentication

Choose exactly one of `apiKey` or `accessToken`. Passing both throws. `workspaceId` is required with `apiKey`. With `accessToken`, omit `workspaceId` only for `GET /v1/workspaces`, and include it for workspace-scoped calls.

```ts
// API key — sends x-api-key and x-air-workspace-id
const air = new AirApi({ apiKey: "...", workspaceId: "..." });

// OAuth — sends Authorization: Bearer …; workspaceId is included only when set
const air = new AirApi({ accessToken: "..." });                    // for discovery
const air = new AirApi({ accessToken: "...", workspaceId: "..." }); // for workspace-scoped calls
```

Some endpoints (notably `GET /v1/workspaces` — see [Workspaces](https://developer.air.inc/page/air-api-sdk#workspaces) below) accept only bearer tokens. Explicit constructor options always win over env vars, so a stray `AIR_ACCESS_TOKEN` in your shell won't conflict with an `apiKey` you pass directly. See the [OAuth helpers](https://developer.air.inc/page/air-api-sdk#oauth-helpers) section for acquiring tokens.

### Environment variables

Instead of passing options directly, you can set environment variables. Set exactly one of `AIR_API_KEY` or `AIR_ACCESS_TOKEN`:

```shell
export AIR_API_KEY=your-api-key
export AIR_WORKSPACE_ID=your-workspace-id
# or, for OAuth:
export AIR_ACCESS_TOKEN=your-oauth-token
export AIR_WORKSPACE_ID=your-workspace-id   # optional under OAuth
```

```ts
const air = new AirApi(); // reads from env automatically
```

### Custom headers

Use `defaultHeaders` to attach headers to every request. This is useful for overriding the default `user-agent` (`air-api-sdk/<version>`) or passing tracking headers:

```ts
const air = new AirApi({
  apiKey: "your-api-key",
  workspaceId: "your-workspace-id",
  defaultHeaders: {
    "user-agent": "my-app/1.0",
    "x-air-client-source": "my-integration",
  },
});
```

Headers are merged in order of precedence (last wins):

1. SDK defaults (`x-api-key` **or**`Authorization: Bearer …`, plus `x-air-workspace-id` when configured, and `user-agent: air-api-sdk/<version>`)
2. `defaultHeaders` from the constructor
3. Per-request `headers` on individual API calls

## Resources

### Boards

Create, read, update, and delete boards. Manage sub-boards, board assets, custom field values, and guest access.

```ts
// List boards
const page = await air.boards.list({ limit: 10 });

// Filter by name or parent
const filtered = await air.boards.list({ name: "My Board", parentBoardId: "board-id" });

// CRUD
const board = await air.boards.create({ title: "New Board", description: "Optional" });
const fetched = await air.boards.get(board.id);
await air.boards.update(board.id, { title: "Renamed" });
await air.boards.delete(board.id);

// Sub-boards
const child = await air.boards.create({ title: "Child", parentBoardId: board.id });

// Board assets
await air.boards.addAssets(board.id, { assetIds: ["asset-id-1", "asset-id-2"] });
await air.boards.removeAsset(board.id, "asset-id-1");

// Board custom fields
await air.boards.setCustomField(board.id, "custom-field-id", { value: "hello" });
await air.boards.setCustomField(board.id, "custom-field-id", { value: null }); // clear

// Guest management
const guest = await air.boards.addGuest(board.id, {
  email: "guest@example.com",
  roleId: "role-id",
});
const guests = await air.boards.listGuests(board.id);
const filteredGuests = await air.boards.listGuests(board.id, { email: "guest@example.com" });
await air.boards.updateGuest(board.id, guest.id, { roleId: "new-role-id" });
await air.boards.removeGuest(board.id, guest.id);
```

### Assets

List, search, and manage assets. Filter by board, tags, custom fields, search query, and date range.

```ts
// List assets
const page = await air.assets.list({ parentBoardId: "board-id", limit: 20 });
const searched = await air.assets.list({ search: "logo" });

// Get and delete
const asset = await air.assets.get("asset-id");
await air.assets.delete("asset-id");

// Custom fields on assets
await air.assets.setCustomField("asset-id", "cf-id", { value: "text value" });
await air.assets.setCustomField("asset-id", "cf-id", { values: [{ id: "value-id" }] }); // select fields

// List boards an asset belongs to
const boards = await air.assets.listBoards("asset-id");
```

### Asset Versions

Each asset can have multiple versions. Manage version metadata, download URLs, and tags.

```ts
const { data: versions } = await air.assets.listVersions("asset-id");
const version = await air.assets.getVersion("asset-id", "version-id");
await air.assets.updateVersion("asset-id", "version-id", { title: "New title" });

// Download URL
const { url } = await air.assets.getVersionDownloadUrl("asset-id", "version-id");

// Version tags
await air.assets.addVersionTag("asset-id", "version-id", { id: "tag-id" });
await air.assets.removeVersionTag("asset-id", "version-id", "tag-id");
```

### Tags

Organize assets with tags. Tags can be applied to individual asset versions.

```ts
const page = await air.tags.list();
const tag = await air.tags.create({ name: "My Tag" });
const fetched = await air.tags.get(tag.id);
await air.tags.update(tag.id, { name: "Renamed Tag" });
await air.tags.delete(tag.id);
```

### Custom Fields

Define custom metadata fields for your workspace. Supports `single-select`, `multi-select`, `plain-text`, and `date` types.

```ts
// List and CRUD
const page = await air.customFields.list();
const cf = await air.customFields.create({
  name: "Status",
  type: "single-select", // 'single-select' | 'multi-select' | 'plain-text' | 'date'
  values: [{ name: "Active" }, { name: "Archived" }],
});
await air.customFields.update(cf.id, { name: "Project Status" });
await air.customFields.delete(cf.id);

// Manage select field values
const value = await air.customFields.createValue(cf.id, { name: "In Review" });
await air.customFields.updateValue(cf.id, value.id, { name: "Under Review" });
await air.customFields.deleteValue(cf.id, value.id);
```

### Roles

List available roles for guest access on boards.

```ts
const roles = await air.roles.list({ type: "guest" });
// Returns: [{ id, name, description, billable, type }]
```

### Uploads

Upload files directly from a file path or buffer. The SDK handles presigned URL generation, multipart uploads for large files, and progress tracking.

```ts
// Upload from file path
const result = await air.uploads.uploadFile(
  { filePath: "./photo.png" },
  { parentBoardId: "board-id" },
);
console.log(result.assetId, result.versionId);

// Upload from buffer
const result = await air.uploads.uploadFile(
  { buffer: myBuffer, fileName: "photo", ext: "png", mime: "image/png" },
  { parentBoardId: "board-id" },
);

// With progress tracking
await air.uploads.uploadFile(
  { filePath: "./video.mp4" },
  {
    parentBoardId: "board-id",
    onProgress: ({ percentage, uploadedBytes, totalBytes }) => {
      console.log(`${percentage}% (${uploadedBytes}/${totalBytes})`);
    },
  },
);

// With tags and custom fields
await air.uploads.uploadFile(
  { filePath: "./doc.pdf" },
  {
    parentBoardId: "board-id",
    tags: [{ id: "tag-id" }],
    customFields: [{ id: "cf-id", value: "some value" }],
  },
);
```

Files over 5 GB are automatically uploaded using multipart upload. When uploading large files from a file path, the SDK streams chunks directly from disk instead of reading the entire file into memory.

For low-level control over the upload process using presigned URLs, use `air.uploads.create()` directly or see the [Upload an Asset](https://developer.air.inc/docs/upload-assets) guide for a step-by-step walkthrough using the raw API.

### Imports

Import assets from external URLs. Air will download and process the file on your behalf.

```ts
// Import from URL
const imp = await air.imports.create({
  sourceUrl: "https://example.com/image.png",
  parentBoardId: "board-id",
  title: "Imported Image",
});
console.log(imp.id, imp.assetId);

// Check import status
const status = await air.imports.getStatus(imp.id);
console.log(status.status); // 'pending' | 'inProgress' | 'succeeded' | 'failed'
```

### Workspaces

Discovery endpoint that lists the workspaces the authenticated principal can access. **Requires OAuth bearer auth** and must not include `x-air-workspace-id`, since this call is used to discover available workspaces before making workspace-scoped requests. API-key auth is not accepted because API-key requests require a workspace ID. The SDK lets you construct a client without a `workspaceId` when using `accessToken` for exactly this reason.

```ts
const air = new AirApi({ accessToken: "..." });
const workspaces = await air.workspaces.list();
// [{ id, name }, ...]
```

Requires the `workspace.read` OAuth scope. See the [OAuth guide](https://developer.air.inc/page/public-api-oauth) for the full bearer-token rules.

## OAuth helpers

The SDK ships helpers for the OAuth 2.0 Authorization Code + PKCE flow so you don't need a separate library to acquire bearer tokens. They return `{ accessToken, tokenType, expiresIn, scope?, refreshToken? }` suitable for passing into `new AirApi({ accessToken })`. Errors surface as the standard `APIError` subclasses (e.g. `AuthenticationError`, `BadRequestError`) and `ConnectionError` on network failures.

The flow itself (endpoints, scopes, consent rules) is documented in the [OAuth guide](https://developer.air.inc/page/air-api-sdk#oauth-helpers) section for acquiring tokens.

### Authorization Code + PKCE (user-facing)

For tools that authenticate on behalf of a human (CLI utilities, desktop apps, server-side OAuth callbacks). The three pieces compose:

```ts
import {
  generatePKCEChallenge,
  buildAuthorizationUrl,
  exchangeAuthorizationCode,
} from "@air/api-sdk";
import { randomBytes } from "node:crypto";

// 1. Generate the PKCE pair and keep the verifier around.
const { codeVerifier, codeChallenge } = generatePKCEChallenge();
const state = randomBytes(16).toString("hex");

// 2. Send the user to Air's consent URL (NOT directly to the authorization
//    server — Air's consent flow records the per-account scope grant and
//    then completes the OAuth handoff).
const url = buildAuthorizationUrl({
  authorizeUrl: "https://app.air.inc/oauth/consent",
  clientId: "YOUR_CLIENT_ID",
  redirectUri: "https://example.com/oauth/air/callback",
  codeChallenge,
  state,
  scopes: ["assets.read", "boards.read"], // bare scope names
});

// 3. After the redirect arrives at your callback URL, exchange the code:
const { accessToken } = await exchangeAuthorizationCode({
  tokenUrl: "https://auth.air.inc/oauth2/token",
  clientId: "YOUR_CLIENT_ID",
  clientSecret: "YOUR_CLIENT_SECRET", // omit for public clients
  code,                                // from the callback query string
  codeVerifier,
  redirectUri: "https://example.com/oauth/air/callback",
});

const air = new AirApi({ accessToken });
```

`generatePKCEChallenge()` returns a 43-character base64url `codeVerifier` (32 random bytes) and the matching `codeChallenge = BASE64URL(SHA256(codeVerifier))`, using `S256` per RFC 7636. `buildAuthorizationUrl()` is a thin URL builder; pass it Air's consent URL as `authorizeUrl`. `exchangeAuthorizationCode()` POSTs to the token endpoint; confidential clients (those with a `clientSecret`) authenticate via HTTP Basic, and public clients send `client_id` in the body. When the OAuth client is configured to issue refresh tokens, the returned `refreshToken` field is populated; store it securely if you intend to refresh access tokens later.

## Pagination

List methods return a `PagePromise` that supports two patterns:

### Auto-pagination

Use `for await` to iterate through all results across pages automatically:

```ts
for await (const asset of air.assets.list({ limit: 50 })) {
  console.log(asset.id);
  // automatically fetches subsequent pages
}
```

### Manual pagination

For more control, await the result and navigate pages explicitly:

```ts
const page = await air.assets.list({ limit: 50 });
console.log(page.data);       // current page items
console.log(page.total);      // total count (when available)
console.log(page.pagination); // { hasMore: boolean, cursor: string | null }

if (page.hasNextPage()) {
  const next = await page.getNextPage();
}
```

## Error Handling

All API errors extend `APIError` with `status`, `body`, and `headers` properties. Specific error classes are thrown based on HTTP status:

| Status | Error Class           | Description                                  |
|--------|-----------------------|----------------------------------------------|
| 400    | `BadRequestError`     | Invalid request parameters                    |
| 401    | `AuthenticationError` | Missing or invalid credentials                |
| 403    | `PermissionError`     | Valid credentials, insufficient permissions   |
| 404    | `NotFoundError`       | Resource doesn't exist                        |
| 429    | `RateLimitError`      | Rate limit exceeded                          |
| 500+   | `InternalServerError` | Server-side error                            |

Network failures throw `ConnectionError`, and timeouts throw `TimeoutError`.

```ts
import { NotFoundError, RateLimitError, APIError } from "@air/api-sdk";

try {
  await air.assets.get("non-existent-id");
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log("Asset not found");
  } else if (err instanceof RateLimitError) {
    console.log(`Rate limited, retry after ${err.retryAfter}s`);
  } else if (err instanceof APIError) {
    console.log(err.status, err.message);
  }
}
```

### Automatic retries

Retryable errors are automatically retried with exponential backoff up to `maxRetries` times (default: 3). You don't need to implement your own retry logic for transient failures.

- **GET and other idempotent methods**: retries on 408, 429, 500, 502, 503, and 504
- **POST requests**: retries on 408 and 429 only (non-idempotent requests are not retried on server errors to avoid duplicate side effects)
