Type-safe TypeScript SDK for the Attio CRM API with Zod v4 runtime validation
npm install attio-ts-sdk





A modern, type-safe TypeScript SDK for the Attio CRM API. Built with Zod v4 and a client layer that adds retries, error normalization, caching, and higher‑level helpers on top of the generated OpenAPI client.
- Full Attio API Coverage - People, companies, lists, notes, tasks, meetings, webhooks, and more
- Create a client in one line - createAttioClient({ apiKey })
- Retry & rate‑limit aware - exponential backoff + Retry-After
- Normalized errors - consistent shape + optional suggestions for select/status mismatches
- Record normalization - handles inconsistent response shapes
- Metadata caching - attributes, select options, statuses
- Pagination helpers - paginate + paginateOffset + cursor handling
- Runtime Validation - Every request and response validated with Zod v4 schemas
- Tree-Shakeable - Import only what you need
- TypeScript First - Complete type definitions generated from OpenAPI spec
You still have full access to the generated, spec‑accurate endpoints.
- attio-js - an alternative SDK generated with Speakeasy
- attio-tui - a TUI for using Attio built with the library
- Migrating to v2
- Installing
- Getting Your API Key
- Usage
- Quick Start
- Recommended Pattern
- Attio SDK
- Attio Convenience Layer
- Value Helpers
- Record Value Accessors
- Schema Helpers
- Client Configuration
- Error Handling
- Pagination Helpers
- Caching
- Debug Hooks
- Metadata Helpers
- Working with Records
- Using Generated Endpoints Directly
- Managing Lists
- Notes and Tasks
- Webhooks
- Development
Version 2.0 brings enhanced type safety, auto-pagination, and new filtering capabilities. Most code will work without changes, but there are a few breaking changes to be aware of.
#### ListId Validation
ListId values can no longer be empty strings. Use the new createListId() factory function:
``typescript
// Before (v1)
const listId = 'sales-pipeline' as ListId;
// After (v2)
import { createListId } from 'attio-ts-sdk';
const listId = createListId('sales-pipeline');
`
The factory validates the input and throws if the string is empty.
#### Strongly Typed Filters
Filter types are now strongly typed instead of Record. If you were passing arbitrary objects as filters, you may need to adjust your code to match the AttioFilter type.
#### Auto-Pagination
queryRecords and queryListEntries now support built-in pagination:
`typescript
// Collect all pages automatically
const allRecords = await queryRecords({
client,
object: 'companies',
paginate: true,
});
// Stream records with async generators (memory-efficient)
for await (const record of queryRecords({
client,
object: 'companies',
paginate: 'stream',
})) {
console.log(record.id);
}
`
#### Type-Safe Response Validation with itemSchema
All record and list entry functions now support itemSchema for Zod validation with full type inference:
`typescript
import { z } from 'zod';
const companySchema = z.object({
id: z.object({ record_id: z.string() }),
values: z.object({
name: z.array(z.object({ value: z.string() })),
}),
});
type Company = z.infer
// TypeScript infers the return type from itemSchema
const companies = await queryRecords
client,
object: 'companies',
itemSchema: companySchema,
paginate: true,
});
// companies is Company[] with full type safety
`
#### New Filter Operators
New comparison and path-based filter operators:
`typescript
import { filters } from 'attio-ts-sdk';
// Comparison operators
filters.lt('revenue', 100000) // Less than
filters.lte('revenue', 100000) // Less than or equal
filters.gt('revenue', 50000) // Greater than
filters.gte('revenue', 50000) // Greater than or equal
filters.in('status', ['active', 'pending']) // Set membership
filters.between('revenue', 50000, 100000) // Range (inclusive start, exclusive end)
// Path-based filters for record reference traversal
filters.path(
[['companies', 'primary_contact']],
{ email: { $contains: '@acme.com' } }
)
`
#### AbortSignal Support
Pagination and query functions now accept signal for request cancellation:
`typescript
const controller = new AbortController();
const records = await queryRecords({
client,
object: 'companies',
paginate: true,
signal: controller.signal,
});
// Cancel in-flight requests
controller.abort();
`
`bashpnpm (recommended)
pnpm add attio-ts-sdk zod
> Note: Zod v4 is a peer dependency - install it alongside the SDK.
Getting Your API Key
1. Log in to your Attio workspace.
2. Navigate to Workspace Settings → Developers (or visit
https://app.attio.com/settings/developers directly).
3. Click Create a new integration, give it a name, and select the scopes your application needs.
4. Copy the generated API token and store it securely (e.g. in an environment variable).`bash
export ATTIO_API_KEY="your-api-key-here"
`The SDK reads the key from whatever you pass to
createAttioClient({ apiKey }) — it does not read environment variables automatically, so you control exactly how the secret is loaded.Usage
This SDK provides two layers:
1) Attio helpers (recommended):
createAttioClient, createRecord, queryRecords, etc.
2) Generated endpoints: getV2Objects, postV2ObjectsByObjectRecordsQuery, etc.$3
`typescript
import { createAttioClient, getV2Objects, postV2ObjectsByObjectRecordsQuery } from 'attio-ts-sdk';// Configure the client with your API key
const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
});
// List all objects in your workspace
const { data: objects } = await getV2Objects({ client });
console.log(objects);
// Query people records
const { data: people } = await postV2ObjectsByObjectRecordsQuery({
client,
path: { object: 'people' },
body: {
limit: 10,
sorts: [{ attribute: 'created_at', direction: 'desc' }],
},
});
`$3
Prefer the Attio convenience layer, throw on errors by default, and unwrap responses with helpers.
This keeps request code compact and consistent.
`typescript
import {
assertOk,
createAttioClient,
createAttioSdk,
getV2Objects,
value,
} from 'attio-ts-sdk';const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
responseStyle: 'data',
throwOnError: true,
});
const sdk = createAttioSdk({ client });
const company = await sdk.records.create({
object: 'companies',
values: {
name: value.string('Acme Corp'),
domains: value.domain('acme.com'),
annual_revenue: value.currency(50000, 'USD'),
},
});
// Use assertOk with generated endpoints when you need raw access
const objects = assertOk(await getV2Objects({ client }));
console.log(objects);
`$3
createAttioSdk builds on top of the convenience layer and the generated endpoints to provide a single, namespaced object you can pass around your application. It binds the client once so you don't repeat { client } on every call, and groups operations by resource.`typescript
import { createAttioSdk } from 'attio-ts-sdk';const sdk = createAttioSdk({ apiKey: process.env.ATTIO_API_KEY });
`The returned
sdk object exposes these namespaces:| Namespace | Methods |
| --- | --- |
|
sdk.objects | list, get, create, update |
| sdk.records | create, update, upsert, get, delete, query |
| sdk.lists | list, get, queryEntries, addEntry, updateEntry, removeEntry |
| sdk.metadata | listAttributes, getAttribute, getAttributeOptions, getAttributeStatuses, schema |The underlying
AttioClient is also available as sdk.client when you need to drop down to the generated endpoints.`typescript
const companies = await sdk.records.query({
object: 'companies',
filter: { attribute: 'name', value: 'Acme' },
});const attributes = await sdk.metadata.listAttributes({
target: 'objects',
identifier: 'companies',
});
// Use the generated endpoints when you need full spec access
const { data } = await getV2Objects({ client: sdk.client });
`$3
The standalone helper functions wrap the generated endpoints with retries, error normalization,
record normalization, and opinionated defaults. They are the same functions that
createAttioSdk uses under the hood — use them directly when you prefer explicit { client } threading.`typescript
import { createAttioClient, createRecord, listLists, searchRecords } from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
const lists = await listLists({ client });
const company = await createRecord({
client,
object: 'companies',
values: {
name: [{ value: 'Acme Corp' }],
domains: [{ domain: 'acme.com' }],
},
});
const matches = await searchRecords({
client,
query: 'acme.com',
objects: ['companies'],
});
`$3
The
value namespace provides factory functions that build correctly shaped field-value arrays for record creation and updates. Each helper validates its input with Zod before returning, so typos and bad data fail fast at the call site rather than in the API response.`typescript
import { value } from 'attio-ts-sdk';
`| Helper | Signature | Description |
| --- | --- | --- |
|
value.string | (value: string) => ValueInput[] | Non-empty string field. |
| value.number | (value: number) => ValueInput[] | Finite numeric field. |
| value.boolean | (value: boolean) => ValueInput[] | Boolean field. |
| value.domain | (value: string) => ValueInput[] | Domain field (non-empty string). |
| value.email | (value: string) => ValueInput[] | Email field (validated format). |
| value.currency | (value: number, currencyCode?: string) => ValueInput[] | Currency field. currencyCode is an optional ISO 4217 code (e.g. "USD"). |`typescript
const values = {
name: value.string('Acme Corp'),
domains: value.domain('acme.com'),
contact_email: value.email('hello@acme.com'),
employee_count: value.number(150),
is_customer: value.boolean(true),
annual_revenue: value.currency(50000, 'USD'),
};await sdk.records.create({ object: 'companies', values });
`$3
getValue and getFirstValue extract attribute values from a record object. Pass an optional Zod schema to get typed, validated results.`typescript
import { getFirstValue, getValue } from 'attio-ts-sdk';// Untyped — returns unknown
const name = getFirstValue(company, 'name');
const domains = getValue(company, 'domains');
// Typed — returns parsed values or throws on mismatch
import { z } from 'zod';
const nameSchema = z.object({ value: z.string() });
const typedName = getFirstValue(company, 'name', { schema: nameSchema });
// ^? { value: string } | undefined
`$3
Create a schema from cached metadata and use accessors to reduce raw string keys:
`typescript
import { createSchema } from 'attio-ts-sdk';const schema = await createSchema({
client,
target: 'objects',
identifier: 'companies',
});
const name = schema.getAccessorOrThrow('name').getFirstValue(company);
`$3
`typescript
import { createAttioClient } from 'attio-ts-sdk';const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
baseUrl: 'https://api.attio.com',
timeoutMs: 20_000,
retry: { maxRetries: 4 },
cache: { enabled: true },
});
`$3
All errors thrown by the convenience layer and
createAttioSdk are normalized into a hierarchy rooted at AttioError:| Class | Default Code | When |
| --- | --- | --- |
|
AttioApiError | (from response) | HTTP error responses (4xx / 5xx). Includes response, requestId, and optional retryAfterMs. |
| AttioNetworkError | (from cause) | Connection failures, DNS errors, timeouts. |
| AttioRetryError | RETRY_ERROR | All retry attempts exhausted. |
| AttioResponseError | RESPONSE_ERROR | Response body failed Zod validation. |
| AttioConfigError | CONFIG_ERROR | Invalid client configuration. |
| AttioBatchError | BATCH_ERROR | A batch operation partially or fully failed. |Every
AttioError carries these optional fields:`typescript
error.status // HTTP status code
error.code // machine-readable error code
error.requestId // Attio x-request-id header
error.retryAfterMs // parsed Retry-After (milliseconds)
error.suggestions // fuzzy-match suggestions for value mismatches (see below)
`#### Catching errors from the convenience layer
`typescript
import { createAttioClient, createRecord, AttioError } from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
try {
await createRecord({
client,
object: 'companies',
values: { stage: [{ value: 'Prospectt' }] },
});
} catch (err) {
if (err instanceof AttioError) {
console.log(err.status, err.code, err.requestId, err.suggestions);
} else {
// Re-throw if it's not an error we specifically handle
throw err;
}
}
`#### Smart suggestions for value mismatches
When an API error indicates a select option or status mismatch, the SDK automatically attaches a
suggestions object with up to three fuzzy-matched alternatives:`typescript
error.suggestions
// {
// field: 'stage',
// attempted: 'Prospectt',
// bestMatch: 'Prospect',
// matches: ['Prospect', 'Prospecting', 'Closed']
// }
`#### Response helpers for generated endpoints
When using the generated endpoints directly, use
assertOk or toResult to unwrap responses:`typescript
import { assertOk, toResult, getV2Objects } from 'attio-ts-sdk';// Throws on error, returns the data payload
const objects = assertOk(await getV2Objects({ client }));
// Returns a discriminated union { ok: true, value } | { ok: false, error }
const result = toResult(await getV2Objects({ client }));
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}
`#### throwOnError mode
You can also opt into exceptions at the client level:
`typescript
const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
throwOnError: true,
});// Generated endpoints now throw instead of returning { error }
const { data } = await postV2ObjectsByObjectRecords({
client,
path: { object: 'companies' },
body: { data: { values: { name: [{ value: 'Test' }] } } },
});
`$3
The SDK provides multiple approaches to pagination, from simple convenience options to low-level helpers for full control.
#### Using
queryRecords with auto-pagination (recommended)The simplest way to paginate record queries is using the
paginate option on queryRecords:`typescript
import { createAttioClient, queryRecords } from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
// Collect all pages automatically into an array
const allCompanies = await queryRecords({
client,
object: 'companies',
filter: { attribute: 'name', value: 'Acme' },
sorts: [{ attribute: 'created_at', direction: 'desc' }],
paginate: true,
maxItems: 10000, // Optional: limit total items
});
// Stream records one at a time (memory-efficient for large datasets)
for await (const company of queryRecords({
client,
object: 'companies',
paginate: 'stream',
})) {
console.log(company.id);
}
`The same pattern works with
queryListEntries / sdk.lists.queryEntries.#### Using low-level pagination helpers
For more control or when working directly with generated endpoints, use
paginateOffset (offset-based) or paginate (cursor-based):| Strategy | Helper | Endpoints |
| --- | --- | --- |
| Offset-based |
paginateOffset | Record queries (postV2ObjectsByObjectRecordsQuery), list entry queries (postV2ListsByListEntriesQuery) |
| Cursor-based | paginate | Meetings (getV2Meetings), notes (getV2Notes), tasks (getV2Tasks), webhooks, and most GET list endpoints |Both helpers automatically extract items and pagination metadata from raw API responses.
#### Paginating record queries with
paginateOffset`typescript
import {
createAttioClient,
paginateOffset,
postV2ObjectsByObjectRecordsQuery,
} from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
// Collect all companies matching a filter across every page
const allCompanies = await paginateOffset(async (offset, limit) => {
return postV2ObjectsByObjectRecordsQuery({
client,
path: { object: 'companies' },
body: {
offset,
limit,
filter: { attribute: 'name', value: 'Acme' },
sorts: [{ attribute: 'created_at', direction: 'desc' }],
},
});
});
`#### Paginating list entry queries with
paginateOffset`typescript
import { paginateOffset, postV2ListsByListEntriesQuery } from 'attio-ts-sdk';const allEntries = await paginateOffset(async (offset, limit) => {
return postV2ListsByListEntriesQuery({
client,
path: { list: 'sales-pipeline' },
body: {
offset,
limit,
filter: { attribute: 'stage', value: 'negotiation' },
},
});
});
`#### Type-safe response validation with itemSchema
The convenience functions
queryListEntries and queryRecords support an optional itemSchema parameter for type-safe validation of API responses. The schema validates raw items before normalization.`typescript
import { z } from 'zod';
import { queryListEntries, createListId } from 'attio-ts-sdk';// Define a schema that matches your expected item structure
const entrySchema = z.object({
id: z.object({ entry_id: z.string() }),
values: z.object({
stage: z.array(z.object({ status: z.string() })),
deal_value: z.array(z.object({ currency_value: z.number() })).optional(),
}),
});
type SalesEntry = z.infer;
// Create a typed ListId using the factory function
const salesListId = createListId('sales-pipeline');
// TypeScript infers the return type from itemSchema
const entries = await queryListEntries({
client,
list: salesListId,
itemSchema: entrySchema,
paginate: true,
});
// entries is SalesEntry[] with full type safety
for (const entry of entries) {
console.log(entry.values.stage[0].status);
}
`When using streaming pagination, the same type safety applies:
`typescript
const stream = queryListEntries({
client,
list: salesListId,
itemSchema: entrySchema,
paginate: 'stream',
});for await (const entry of stream) {
console.log(entry.values.stage[0].status);
}
`#### Paginating cursor-based endpoints
`typescript
import { paginate, getV2Meetings } from 'attio-ts-sdk';const allMeetings = await paginate(async (cursor) => {
return getV2Meetings({ client, query: { cursor } });
});
`#### Pagination options
Both helpers accept an options object to control limits:
`typescript
// Offset-based options
const records = await paginateOffset(fetchPage, {
offset: 0, // starting offset (default: 0)
limit: 100, // items per page (default: 50)
maxPages: 5, // stop after N pages
maxItems: 200, // stop after N total items
});// Cursor-based options
const meetings = await paginate(fetchPage, {
cursor: null, // starting cursor (default: null)
maxPages: 10, // stop after N pages
maxItems: 500, // stop after N total items
});
`$3
The SDK includes two levels of caching to reduce API calls and improve performance:
#### Metadata Caching
Attribute metadata (attributes, select options, and statuses) is automatically cached with a 5-minute TTL. This reduces redundant API calls when working with the same objects repeatedly.
`typescript
import { getAttributeOptions, getAttributeStatuses, listAttributes } from 'attio-ts-sdk';// These calls are cached for 5 minutes
const options = await getAttributeOptions({
client,
target: 'objects',
identifier: 'companies',
attribute: 'stage',
});
// Subsequent calls with the same parameters return cached data
const optionsAgain = await getAttributeOptions({
client,
target: 'objects',
identifier: 'companies',
attribute: 'stage',
}); // Returns cached result, no API call
`The metadata caches have the following defaults:
- Attributes cache: 200 entries max
- Options cache: 500 entries max
- Statuses cache: 500 entries max
When a cache reaches its limit, the oldest entry is evicted.
You can customize TTL, max entries, and adapters per client:
`typescript
const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
cache: {
enabled: true,
metadata: {
ttlMs: 2 60 1000,
maxEntries: { attributes: 300, options: 800, statuses: 800 },
adapter: {
create: ({ scope, ttlMs, maxEntries }) =>
new YourCacheAdapter({ scope, ttlMs, maxEntries }),
},
},
},
});// Clear metadata caches for this client
client.cache.clear();
`#### Client Instance Caching
You can cache
AttioClient instances to reuse them across your application. This is useful when you want to avoid creating new client instances for repeated operations.`typescript
import { getAttioClient } from 'attio-ts-sdk';// With cache.key set, the client instance is cached and reused
const client = getAttioClient({
apiKey: process.env.ATTIO_API_KEY,
cache: { key: 'my-app' },
});
// Returns the same cached client instance
const sameClient = getAttioClient({
apiKey: process.env.ATTIO_API_KEY,
cache: { key: 'my-app' },
});
// Disable caching if needed
const freshClient = getAttioClient({
apiKey: process.env.ATTIO_API_KEY,
cache: { enabled: false },
});
`$3
You can tap into request/response/error lifecycles for logging and tracing.
`typescript
const client = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
hooks: {
onRequest: ({ request }) => console.log("request", request.method, request.url),
onResponse: ({ response }) => console.log("response", response.status),
onError: ({ error }) => console.error("error", error.message),
},
});// Or wire a logger (debug/info/warn/error)
const clientWithLogger = createAttioClient({
apiKey: process.env.ATTIO_API_KEY,
logger: console,
});
`Note:
createAttioClient always creates a new client instance. Use getAttioClient when you want caching behavior.$3
`typescript
import { createAttioClient, getAttributeOptions } from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
const options = await getAttributeOptions({
client,
target: 'objects',
identifier: 'companies',
attribute: 'stage',
});
`$3
`typescript
import {
createAttioClient,
createRecord,
upsertRecord,
getRecord,
deleteRecord,
} from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
// Create a new company
const newCompany = await createRecord({
client,
object: 'companies',
values: {
name: [{ value: 'Acme Corp' }],
domains: [{ domain: 'acme.com' }],
},
});
// Upsert a record (create or update based on matching attribute)
const upserted = await upsertRecord({
client,
object: 'companies',
matchingAttribute: 'domains',
values: {
name: [{ value: 'Acme Corp' }],
domains: [{ domain: 'acme.com' }],
description: [{ value: 'Updated description' }],
},
});
// Get a specific record
const company = await getRecord({
client,
object: 'companies',
recordId: 'abc-123',
});
// Delete a record
await deleteRecord({
client,
object: 'companies',
recordId: 'abc-123',
});
`$3
You can always call the generated endpoints for full spec coverage:
`typescript
import { createAttioClient, getV2Objects } from 'attio-ts-sdk';const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
const { data: objects } = await getV2Objects({ client });
`$3
`typescript
import {
getV2Lists,
postV2ListsByListEntriesQuery,
postV2ListsByListEntries,
} from 'attio-ts-sdk';// Get all lists
const { data: lists } = await getV2Lists({ client });
// Query entries in a list
const { data: entries } = await postV2ListsByListEntriesQuery({
client,
path: { list: 'sales-pipeline' },
body: {
filter: {
attribute: 'stage',
value: 'negotiation',
},
},
});
// Add a record to a list
const { data: entry } = await postV2ListsByListEntries({
client,
path: { list: 'sales-pipeline' },
body: {
data: {
parent_record_id: 'company-record-id',
entry_values: {
stage: [{ status: 'prospecting' }],
deal_value: [{ currency_value: 50000 }],
},
},
},
});
`$3
`typescript
import { postV2Notes, postV2Tasks, patchV2TasksByTaskId } from 'attio-ts-sdk';// Create a note on a record
const { data: note } = await postV2Notes({
client,
body: {
data: {
parent_object: 'companies',
parent_record_id: 'abc-123',
title: 'Meeting Notes',
content: 'Discussed Q4 roadmap...',
},
},
});
// Create a task
const { data: task } = await postV2Tasks({
client,
body: {
data: {
content: 'Follow up on proposal',
deadline_at: '2024-12-31T17:00:00Z',
linked_records: [{ target_object: 'companies', target_record_id: 'abc-123' }],
},
},
});
// Mark task as complete
await patchV2TasksByTaskId({
client,
path: { task_id: task.data.id.task_id },
body: { data: { is_completed: true } },
});
`$3
`typescript
import { postV2Webhooks, getV2Webhooks } from 'attio-ts-sdk';// Create a webhook
const { data: webhook } = await postV2Webhooks({
client,
body: {
data: {
target_url: 'https://your-app.com/webhooks/attio',
subscriptions: [
{ event_type: 'record.created', filter: { object: 'companies' } },
{ event_type: 'record.updated', filter: { object: 'companies' } },
],
},
},
});
// List all webhooks
const { data: webhooks } = await getV2Webhooks({ client });
`See Also
- attio-js - an alternative SDK generated with Speakeasy
- attio-tui - a TUI for using Attio built with this library
Development
$3
- Hey API: OpenAPI client and Zod schema generation
- Biome: lint and format with a single tool
- Vitest: fast tests with coverage and thresholds
- tsdown: ESM builds for Node
- CI: lint, typecheck, test, coverage, and size comments/badges
- Deno-friendly:
.ts source imports for direct consumption
- OIDC + Provenance: publish to npm and JSR via manual CI release$3
Install dependencies and run scripts:
`bash
git clone git@github.com:hbmartin/attio-ts-sdk.git
cd attio-ts-sdk
pnpm i
pnpm lint
pnpm test
pnpm build
``