attio-ts-sdk
v2.1.0
Published
Type-safe TypeScript SDK for the Attio CRM API with Zod v4 runtime validation
Downloads
1,291
Maintainers
Readme
Attio CRM TypeScript 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.
See Also
- attio-js - an alternative SDK generated with Speakeasy
- attio-tui - a TUI for using Attio built with the library
Table of Contents
Migrating to v2
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.
Breaking Changes
ListId Validation
ListId values can no longer be empty strings. Use the new createListId() factory function:
// 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<string, unknown>. If you were passing arbitrary objects as filters, you may need to adjust your code to match the AttioFilter type.
New Features
Auto-Pagination
queryRecords and queryListEntries now support built-in pagination:
// 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:
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<typeof companySchema>;
// TypeScript infers the return type from itemSchema
const companies = await queryRecords<Company>({
client,
object: 'companies',
itemSchema: companySchema,
paginate: true,
});
// companies is Company[] with full type safetyNew Filter Operators
New comparison and path-based filter operators:
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:
const controller = new AbortController();
const records = await queryRecords({
client,
object: 'companies',
paginate: true,
signal: controller.signal,
});
// Cancel in-flight requests
controller.abort();Installing
# pnpm (recommended)
pnpm add attio-ts-sdk zod
# npm
npm install attio-ts-sdk zod
# yarn
yarn add attio-ts-sdk zod
# bun
bun add attio-ts-sdk zodNote: Zod v4 is a peer dependency - install it alongside the SDK.
Getting Your API Key
- Log in to your Attio workspace.
- Navigate to Workspace Settings → Developers (or visit
https://app.attio.com/settings/developersdirectly). - Click Create a new integration, give it a name, and select the scopes your application needs.
- Copy the generated API token and store it securely (e.g. in an environment variable).
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:
- Attio helpers (recommended):
createAttioClient,createRecord,queryRecords, etc. - Generated endpoints:
getV2Objects,postV2ObjectsByObjectRecordsQuery, etc.
Quick Start
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' }],
},
});Recommended Pattern
Prefer the Attio convenience layer, throw on errors by default, and unwrap responses with helpers. This keeps request code compact and consistent.
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);Attio SDK
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.
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.
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 });Attio Convenience Layer
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.
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'],
});Value Helpers
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.
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"). |
const values = {
name: value.string('Acme Corp'),
domains: value.domain('acme.com'),
contact_email: value.email('[email protected]'),
employee_count: value.number(150),
is_customer: value.boolean(true),
annual_revenue: value.currency(50000, 'USD'),
};
await sdk.records.create({ object: 'companies', values });Record Value Accessors
getValue and getFirstValue extract attribute values from a record object. Pass an optional Zod schema to get typed, validated results.
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 } | undefinedSchema Helpers
Create a schema from cached metadata and use accessors to reduce raw string keys:
import { createSchema } from 'attio-ts-sdk';
const schema = await createSchema({
client,
target: 'objects',
identifier: 'companies',
});
const name = schema.getAccessorOrThrow('name').getFirstValue(company);Client Configuration
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 },
});Error Handling
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:
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
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:
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:
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:
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' }] } } },
});Pagination Helpers
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:
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
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
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.
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<typeof entrySchema>;
// Create a typed ListId using the factory function
const salesListId = createListId('sales-pipeline');
// TypeScript infers the return type from itemSchema
const entries = await queryListEntries<SalesEntry>({
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:
const stream = queryListEntries<SalesEntry>({
client,
list: salesListId,
itemSchema: entrySchema,
paginate: 'stream',
});
for await (const entry of stream) {
console.log(entry.values.stage[0].status);
}Paginating cursor-based endpoints
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:
// 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
});Caching
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.
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 callThe 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:
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.
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 },
});Debug Hooks
You can tap into request/response/error lifecycles for logging and tracing.
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.
Metadata Helpers
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',
});Working with Records
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',
});Using Generated Endpoints Directly
You can always call the generated endpoints for full spec coverage:
import { createAttioClient, getV2Objects } from 'attio-ts-sdk';
const client = createAttioClient({ apiKey: process.env.ATTIO_API_KEY });
const { data: objects } = await getV2Objects({ client });Managing Lists
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 }],
},
},
},
});Notes and Tasks
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 } },
});Webhooks
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
Tools
- 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:
.tssource imports for direct consumption - OIDC + Provenance: publish to npm and JSR via manual CI release
Setup
Install dependencies and run scripts:
git clone [email protected]:hbmartin/attio-ts-sdk.git
cd attio-ts-sdk
pnpm i
pnpm lint
pnpm test
pnpm build