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).
npm install @air/api-sdk
# or
yarn add @air/api-sdk
# or
bun add @air/api-sdkQuick Start
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 guide.
Configuration
Constructor options
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 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.
// 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 callsSome endpoints (notably GET /v1/workspaces — see 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 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:
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 OAuthconst air = new AirApi(); // reads from env automaticallyCustom 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:
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):
- SDK defaults (
x-api-keyorAuthorization: Bearer …, plusx-air-workspace-idwhen configured, anduser-agent: air-api-sdk/<version>) defaultHeadersfrom the constructor- Per-request
headerson individual API calls
Resources
Boards
Create, read, update, and delete boards. Manage sub-boards, board assets, custom field values, and guest access.
// 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: "[email protected]",
roleId: "role-id",
});
const guests = await air.boards.listGuests(board.id);
const filteredGuests = await air.boards.listGuests(board.id, { email: "[email protected]" });
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.
// 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.
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.
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.
// 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.
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.
// 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 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.
// 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.
const air = new AirApi({ accessToken: "..." });
const workspaces = await air.workspaces.list();
// [{ id, name }, ...]Requires the workspace.read OAuth scope. See the OAuth guide 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. The helpers below cover the SDK-specific surface.
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:
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:
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:
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.
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)