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-sdk

Quick 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",
  },
});
OptionTypeDefaultDescription
apiKeystringAIR_API_KEY env varAPI key for authentication
accessTokenstringAIR_ACCESS_TOKEN env varOAuth 2.0 bearer token
workspaceIdstringAIR_WORKSPACE_ID env varTarget workspace ID
baseURLstringhttps://api.air.inc/v1API base URL
maxRetriesnumber3Max automatic retries for retryable errors
timeoutnumber60000Request timeout in milliseconds
defaultHeadersobjectundefinedHeaders 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 calls

Some 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 OAuth
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:

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.

// 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:

StatusError ClassDescription
400BadRequestErrorInvalid request parameters
401AuthenticationErrorMissing or invalid credentials
403PermissionErrorValid credentials, insufficient permissions
404NotFoundErrorResource doesn't exist
429RateLimitErrorRate limit exceeded
500+InternalServerErrorServer-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)