Skip to content

Integrating ClickHouse with Attio

Sync Attio into ClickHouse to query CRM records, relationships, list membership, notes, and tasks alongside product or business data.

Attio logo

The app registry installs the Attio integration as editable TypeScript source. It includes nine raw tables, separate streams for each configured object type or list, workspace-wide resource streams, and three SQL views for people, companies, and deals. The default configuration creates nine streams writing to seven resource tables; entries and list-attribute readers require configured lists. Every run performs full reads through the Attio REST API and updates the latest observed versions in ClickHouse.

PropertyIncluded behavior
Registry appattio (v0.2.0)
Install commandbunx chkit add attio
Source directorysrc/integrations/attio
AuthenticationBearer token
Environment variablesATTIO_API_TOKEN
ClickHouse>=25.3.0
Coverage9 synced resources, 3 derived views
Sync strategyFull scans

Run these commands in a TypeScript project with a package.json. Use a chkit release that includes the registry and ingestion plugin; the examples use the beta release.

Terminal window
bun add -d chkit@beta
bunx chkit registry inspect attio
bunx chkit add attio --dry-run
bunx chkit add attio

The installer copies the provider source, installs compatible dependencies, registers the ingestion plugin, and connects the exported schemas and pipeline to the project’s config. An existing project’s schema definitions and connection settings remain in place. See installation behavior for supported config shapes and conflicts.

add prepares the project. Migrations create the ClickHouse objects, and ingest run performs the API calls. The registry is only used to obtain source files; subsequent syncs execute the installed code.

For a new project, the generated clickhouse.config.ts has this shape:

import { defineConfig } from '@chkit/core'
import { ingest } from '@chkit/plugin-ingest'
export default defineConfig({
entry: './src/integrations/attio/index.ts',
plugins: [ingest()],
clickhouse: {
url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
username: process.env.CLICKHOUSE_USER ?? 'default',
password: process.env.CLICKHOUSE_PASSWORD ?? '',
database: process.env.CLICKHOUSE_DB ?? 'default',
},
})

Set the connection variables for the intended target. Keep the existing entry, schema paths, and plugins when adapting an existing project. Ingestion requires a direct connection; workbench-only authentication does not provide an ingestion destination.

The copied config.ts separately controls the database of the Attio tables and views. Set its database to the intended destination too; changing CLICKHOUSE_DB alone does not rename those schema objects.

A workspace admin creates a single-workspace access token in Attio. The following steps use Attio’s API-key setup instructions:

  1. As a workspace admin, open the workspace-name dropdown in Attio and select Workspace settings.
  2. Open Developers, select + New access token, and name the token (for example, chkit ClickHouse).
  3. Grant the read scopes listed for the resources to sync.
  4. Click the token on the Developers page to copy it. Set ATTIO_API_TOKEN in the project .env file or the scheduler secret environment.

Attio credential setup

Grant read access to the categories required by the enabled streams. The scope table covers all supported resources; list-entry and list-attribute readers run only when lists is configured. No write scopes are needed.

ResourceRequired scopes
Objectsobject_configuration:read
Object attributesobject_configuration:read
Recordsobject_configuration:read, record_permission:read
Listslist_configuration:read
List attributeslist_configuration:read
List entrieslist_configuration:read, list_entry:read
Notesnote:read, object_configuration:read, record_permission:read
Taskstask:read, object_configuration:read, record_permission:read, user_management:read
Workspace membersuser_management:read

Configure the token in the project and scheduler

Section titled “Configure the token in the project and scheduler”

Set the copied token in the project’s .env, replacing the placeholder:

Terminal window
ATTIO_API_TOKEN=replace-with-the-access-token-from-attio

Bun loads the project’s .env; a scheduler must supply ATTIO_API_TOKEN through its own secret or environment configuration, together with the ClickHouse connection variables. The installer writes required names to .env.example, which does not load credentials. Keep real tokens out of source control.

The reader sends the token in an Authorization: Bearer header when a request runs. Schema inspection, migration generation, and ingest list do not need an Attio token. An existing OAuth access token also works, but this integration does not obtain or refresh OAuth tokens; see Attio authentication.

A token’s permissions also determine which data the API returns. A 401 or 403 fails the affected stream with a diagnostic; the reader does not turn a permission error into an empty result.

Choose objects, lists, and destination names

Section titled “Choose objects, lists, and destination names”

One installation pipeline groups the independently selectable resource streams. createAttioPipeline(config, deps) snapshots reader settings and configured collections; raw table placement comes from the installation’s setup-time attioConfig before schema discovery.

The installed src/integrations/attio/config.ts starts with:

export interface AttioConfig {
sourceId: string
database: string
tablePrefix: string
objects: readonly string[]
lists: readonly string[]
pageSize: number
notesPageSize: number
}
export const attioConfig: AttioConfig = {
sourceId: 'attio.primary',
database: 'default',
tablePrefix: 'attio',
objects: ['people', 'companies'],
lists: [],
pageSize: 500,
notesPageSize: 50,
}
SettingEffect
sourceIdStable, non-secret identity used in stream IDs and row IDs
databaseDatabase containing the nine raw tables and three views
tablePrefixPrefix used before _records_raw, _people, and the other suffixes
objectsExplicit object API slugs or UUIDs; default ['people', 'companies']; creates records and attribute streams per type
listsExplicit list API slugs or UUIDs; default []; creates entries and attribute streams per list
pageSizePositive integer, default 500, for attributes, records, entries, and tasks
notesPageSizePositive integer up to 50, default 50, for notes

Add deals or a custom slug to objects to collect that type, and add list slugs or IDs to lists to collect their entries. Empty arrays disable the corresponding child streams. The objects and lists catalog streams retain all accessible metadata independently of these selections. An unknown or inaccessible configured slug or ID fails its stream visibly. Notes, tasks, and members remain workspace-wide; their readers need separate changes to narrow their coverage.

The pipeline creates its stream graph from these explicit arrays without API discovery. People and Companies have independent readers and journal progress, while both write to attio_records_raw. An object stream’s ID ends in .records.<ref> or .object_attributes.<ref>; a list stream’s ID ends in .entries.<ref> or .list_attributes.<ref>. Keep each configured reference stable: switching a slug to an equivalent UUID changes the stream ID.

Keep sourceId stable after ingestion starts. For a second workspace, use a distinct source identity and a separate table prefix or database. Copying the directory alone does not isolate pipeline IDs or destination tables.

Terminal window
bunx chkit check
bunx chkit ingest list --tag provider:attio
bunx chkit generate --name add-attio
bunx chkit migrate

Review the generated SQL, then apply it and run ingestion:

Terminal window
bunx chkit migrate --apply
bunx chkit ingest run --tag provider:attio
bunx chkit query "SELECT count() FROM default.attio_records_raw FINAL"
bunx chkit query "SELECT name, domains FROM default.attio_companies LIMIT 10"

Queries in this guide use the default database and prefix. Substitute the configured names when these differ.

Each object type has its own records and attribute streams, and each configured list has its own entries and attribute streams. Catalogs, notes, tasks, and members have workspace-wide streams. Streams for the same resource share a raw table. This table lists the returned entity data retained in raw.data; the complete API entity is preserved, including additional fields returned by Attio. Fields may be absent or null depending on the resource and workspace configuration.

ResourceDefault ClickHouse tableRecords syncedAPI reference
Objects (objects)attio_objects_rawAccessible standard and custom object definitions, IDs, API slug, singular and plural names, creation timeGET/objects
Object attributes (object_attributes)attio_object_attributes_rawAttribute IDs, slug, title, description, type, flags, defaults, relationship definitions, configuration, creation time; includes archived definitionsGET/objects/{object_id}/attributes, GET/objects/{object_id}
Records (records)attio_records_rawIndependent records streams for configured object types (People and Companies by default), IDs, creation time, Attio URL, and the complete values object with all returned attribute arraysPOST/objects/{object_id}/records/query, GET/objects/{object_id}
Lists (lists)attio_lists_rawList IDs, slug, name, parent objects, workspace and member access configuration, creator, creation timeGET/lists
List attributes (list_attributes)attio_list_attributes_rawAttribute definitions for selected lists, including types, configuration, flags, defaults, relationships, and archived definitionsGET/lists/{list_id}/attributes, GET/lists/{list_id}
List entries (entries)attio_entries_rawEntry IDs, parent object and record ID, creation time, and the complete entry_values objectPOST/lists/{list_id}/entries/query, GET/lists/{list_id}
Notes (notes)attio_notes_rawNote IDs, parent record links, title, plaintext and Markdown content, tags, creator, creation time, and meeting reference when returnedGET/notes
Tasks (tasks)attio_tasks_rawTask IDs, plaintext content, deadline, completion state and time, linked records, assignees, creator, creation timeGET/tasks
Workspace members (members)attio_members_rawMember IDs, first and last name, email address, avatar URL, access level, creation timeGET/workspace_members

The default records streams read People and Companies separately. Deals and custom objects require explicit slugs or IDs in objects; configured types all land in the same records table. A users object, when configured, contains CRM records; workspace members are collected separately. The object catalog exposes accessible types without automatically adding readers for them.

Record and entry attributes remain arrays, including multivalued custom fields and relationship references. Returned attribute values keep their accompanying metadata. The integration does not issue separate requests for historical attribute values, select-option catalogs, or status catalogs. See the record API, entry API, and attribute API for the provider’s response shapes.

Notes retain the content and references in the notes response; a meeting reference does not fetch the meeting itself. Task links and assignees remain in the task payload. Member identity fields come from the workspace members endpoint.

Every API path in the resource table is relative to https://api.attio.com/v2. An object/list child reader first resolves its single configured slug or ID with an individual parent request, then fetches that parent’s collection.

ResourcePagination and filters
objectsSingle collection request retaining all accessible objects
object_attributeslimit and offset query parameters; show_archived=true
recordslimit and offset in JSON body; no record filter or custom sort
listsSingle collection request retaining all accessible lists
list_attributeslimit and offset query parameters; show_archived=true
entrieslimit and offset in JSON body; no entry filter or custom sort
noteslimit and offset query parameters; no parent filter
taskslimit and offset query parameters; sort=created_at:asc; no completion filter
membersSingle collection request

Paginated reads use the ingestion plugin’s paginate() helper: they start at offset zero, advance by the number of returned entities, and stop when a page is shorter than the requested limit. Each reader resolves only its configured object/list, so running People records does not depend on the objects catalog having run first. The pipeline’s stream array does not establish dependencies.

BehaviorDetails
SyncPeople, Companies, each configured custom object/list, and workspace resources have independent full-sync streams. Each reader paginates one collection through chkit-ingest; interrupted mutable offset reads replay that collection from zero. Completed and empty reads are recorded by the executor journal without a custom scan checkpoint. Custom types and lists must be configured explicitly.
ScheduleRun chkit ingest run through cron, CI, or an existing scheduler; the template does not start a background worker.
DeletionsRecords absent from a later API response remain in ClickHouse. The template does not propagate deletions or consume webhooks.
  1. The CLI loads the installed pipeline and selects streams by their tags.
  2. Each selected reader resolves its one configured object/list where needed and fetches that collection’s pages.
  3. The client validates the response’s data array and required entity IDs. Record values and entry entry_values must contain arrays.
  4. The reader wraps each entity with its source identity and relevant parent slug, then assigns a stable composite row ID.
  5. The runtime batches rows and writes them to ClickHouse. The journal records execution and load progress.
  6. SQL views project the raw records and reconcile repeated row IDs at query time with FINAL.

Every stream uses fullSync() and begins its next execution at offset zero. Attio’s offsets describe positions in mutable collections and are not durable change cursors. A failed Companies reader therefore replays Companies independently of People; it does not maintain an outer loop over object types or a completed-parent ledger. The executor journal records execution and load completion, including successful empty reads, without custom provider checkpoint state.

Child response workspace and parent IDs are checked against the reader’s resolved object/list before rows are published. Adding a configured type creates a new stream; removing it stops future reads without removing existing rows. Every scheduled run rereads the selected collections, so edits remain observable.

Upgrading from 0.1.2 to 0.2.0 changes the former combined records, object-attributes, entries, and list-attributes stream IDs to per-type/per-list IDs. Those streams begin with fresh journal progress; the raw table schemas, row IDs for the same sourceId, and views are unchanged. Installed files are not automatically overwritten by a newer registry release.

The destination stores the latest observed entity. New observations replace earlier observations for the same row identity when ClickHouse reconciles versions. Freshness depends on the external schedule, scan duration, and successful API responses. There are no webhooks or continuous change capture in this integration.

ControlAttio integration default
Active streams, fetches, and loadsOne of each per pipeline within a run
Destination batch threshold500 rows; pages are accumulated without splitting a source chunk
HTTP request timeout30 seconds
Delay before a request25 ms; 125 ms for notes
Source retry budgetFive retries after the initial attempt
BackoffExponential, starting at 1 second, capped at 30 seconds, with jitter
Retry time budget10 minutes per retry boundary, subject to the overall execution budget
Default run duration3600 seconds; override with --max-duration

HTTP 429 responses honor Retry-After. Network failures and HTTP 408, 425, and 5xx responses are retried within the budget. Other 4xx responses, invalid configuration, invalid JSON, and invalid entity shapes fail without retries. Exhausting a page request’s retries fails the stream; restarting the reader does not multiply that request’s retry budget.

The request delays keep the default serial pipeline below 40 requests per second and notes below 8 requests per second, before network time. Attio can apply lower limits, including query-complexity limits; repeatedly expensive record or entry queries may require simpler filters or sorts after customization. See Attio rate limits.

Destination writes have a separate retry policy and reuse an insert deduplication token. Physical duplicate suppression depends on ClickHouse’s deduplication settings and window. The logical row identity and FINAL queries reconcile repeated observations; this is not an exactly-once delivery guarantee.

Writes become visible batch by batch. A failed or interrupted run can leave already loaded pages visible, with no rollback to the previous complete workspace snapshot. A normal stream failure allows other selected streams to be attempted. ingest run exits with 0 only when all selected streams succeed; an incomplete execution exits with 1.

Every raw table uses the same five columns:

ColumnClickHouse typeMeaning
idStringJSON-encoded composite identity for the source, resource, and entity IDs
rawJSONEnvelope containing source_id, optional parent slug, and the complete entity under data
_chkit_batch_idStringRuntime batch identity
_chkit_run_idStringIngestion run identity
_chkit_ingested_atDateTime64(6, 'UTC')ClickHouse publication time, populated with now64(6)

Tables use ReplacingMergeTree(_chkit_ingested_at) with id as both the primary key and ordering key. Later ingestion time wins during replacement; the integration does not order rows by an Attio source version. Physical rows can include earlier observations until merges occur. Use FINAL when directly querying raw tables for the reconciled result.

Each ID is built as JSON.stringify([sourceId, resource, ...entityIds]). The resource names and entity ID fields are:

ResourceEntity ID fields, in orderExtra envelope field
objectsworkspace_id, object_idNone
object_attributesworkspace_id, object_id, attribute_idobject_slug
recordsworkspace_id, object_id, record_idobject_slug
listsworkspace_id, list_idNone
list_attributesworkspace_id, object_id, attribute_idlist_slug
entriesworkspace_id, list_id, entry_idlist_slug
notesworkspace_id, note_idNone
tasksworkspace_id, task_idNone
membersworkspace_id, workspace_member_idNone

Attio’s shared attribute response calls the parent list’s ID object_id; the list-attribute key follows that response shape. Names, email addresses, and domains are never row keys. The resource name is part of id, not an additional field in raw.

A record envelope has this shape, with illustrative IDs:

{
"source_id": "attio.primary",
"object_slug": "people",
"data": {
"id": {
"workspace_id": "workspace-id",
"object_id": "people-object-id",
"record_id": "person-record-id"
},
"created_at": "2026-01-01T00:00:00Z",
"web_url": "https://app.attio.com/example/person/person-record-id",
"values": {
"name": [{ "full_name": "Ada Example" }],
"email_addresses": [{ "email_address": "ada@example.test" }]
}
}
}

The original entity lives under data, so custom provider fields cannot overwrite chkit’s envelope metadata. Nested arrays and fields omitted from the packaged views remain available for new SQL projections.

The integration creates ordinary SQL views over attio_records_raw FINAL, filtered by object_slug. These views project the collected record rows; they do not make additional API requests.

Derived viewSource resourceIncluded projection
attio_peoplerecordsNames, email addresses, and linked companies for people records.
attio_companiesrecordsNames, domains, and descriptions for company records.
attio_dealsrecordsDeal names, stages, values, currencies, and linked people and companies.

Each view shares these columns:

ColumnMeaning
idStable composite raw row ID
source_idConfigured source identity
workspace_idAttio workspace ID
object_idAttio object ID
record_idAttio record ID
created_atSource creation time parsed as a nullable UTC timestamp with microsecond precision
web_urlRecord URL in Attio
_chkit_ingested_atTime this observation was published to ClickHouse

The additional projections use these paths relative to raw.data.values. [1] means the first array element, following ClickHouse indexing.

ViewColumnSource value
attio_peoplenamename[1].full_name
attio_peopleemail_addressesAll email_addresses[].email_address values
attio_peoplecompany_record_idsAll company[].target_record_id values
attio_companiesnamename[1].value
attio_companiesdomainsAll domains[].domain values
attio_companiesdescriptiondescription[1].value
attio_dealsnamename[1].value
attio_dealsstagestage[1].status.title
attio_dealsvaluevalue[1].currency_value, parsed as Nullable(Float64)
attio_dealscurrency_codevalue[1].currency_code
attio_dealscompany_record_idsAll associated_company[].target_record_id values
attio_dealspeople_record_idsAll associated_people[].target_record_id values

Missing optional strings become empty strings; absent arrays become empty arrays. Missing or unparseable dates and amounts become null. On an initial sync without a selected deals object, the deals view is empty. Deselecting an object later leaves previously observed rows intact. Custom object records remain in the raw table and do not receive automatic typed views.

Count the latest observed records by object:

SELECT
JSONExtractString(toJSONString(raw), 'object_slug') AS object_slug,
count() AS records,
max(_chkit_ingested_at) AS last_observed_at
FROM default.attio_records_raw FINAL
GROUP BY object_slug
ORDER BY records DESC;

Aggregate deal value by stage and currency:

SELECT
stage,
currency_code,
count() AS deals,
sum(value) AS total_value
FROM default.attio_deals
GROUP BY stage, currency_code
ORDER BY currency_code, total_value DESC;

Grouping by currency avoids combining amounts from different currencies. The packaged amount is a floating-point projection; edit it to use a suitable decimal representation when exact monetary arithmetic is required.

Inspect list membership and retain custom entry attributes:

WITH toJSONString(raw) AS payload
SELECT
JSONExtractString(payload, 'list_slug') AS list_slug,
JSONExtractString(payload, 'data', 'id', 'entry_id') AS entry_id,
JSONExtractString(payload, 'data', 'parent_object') AS parent_object,
JSONExtractString(payload, 'data', 'parent_record_id') AS parent_record_id,
JSONExtractRaw(payload, 'data', 'entry_values') AS entry_values
FROM default.attio_entries_raw FINAL
LIMIT 20;

Project a custom company field:

WITH toJSONString(raw) AS payload
SELECT
JSONExtractString(payload, 'data', 'id', 'record_id') AS record_id,
JSONExtractString(payload, 'data', 'values', 'name', 1, 'value') AS company,
JSONExtractString(payload, 'data', 'values', 'customer_tier', 1, 'value') AS customer_tier
FROM default.attio_records_raw FINAL
WHERE JSONExtractString(payload, 'object_slug') = 'companies';

The last example assumes a text attribute with API slug customer_tier. Adapt the slug and value shape to the attribute definitions in attio_object_attributes_raw. Preserve source_id and workspace_id when joining records across sources, and use the provider’s record IDs for relationships.

After the first successful run, schedule this finite command through cron, CI, or a job runner:

Terminal window
bunx chkit ingest run --tag provider:attio --max-duration 3600 --json

The scheduler supplies the project directory, credentials, runtime, cadence, and concurrency control. Run at most one ingestion process per project and ClickHouse target at a time, including runs that select different tags. Pipeline limits apply within one process and do not lock out another process.

For a narrower run, select all configured records streams:

Terminal window
bunx chkit ingest list --tag provider:attio --tag resource:records
bunx chkit ingest run --tag provider:attio --tag resource:records --json
bunx chkit ingest status --tag provider:attio --json

Repeated tags use AND matching. Add --tag object:people to select only People records. Its record stream carries resource:records and object:people; its attribute stream carries resource:object_attributes and object:people. With the default source ID, --tag stream:attio.primary.records.people selects the People record stream. List readers carry list:<ref> together with resource:entries or resource:list_attributes.

Inspect the run’s per-stream outcomes and error messages. status reports committed execution state rather than complete run history. Keep the ingestion journal as durable operational state. After fixing a token, permission, or destination issue, rerun the affected stream; its collection starts at offset zero.

Verify a known Attio record against its retained raw.data, update that record in a test workspace, rerun the sync, and confirm its view reflects the new observation. A row count alone does not establish full API coverage. These full-sync readers ignore date bounds and do not support date-range backfills; implementing historical selection requires a source-specific strategy.

SituationCurrent behavior
Source record deleted or mergedPreviously observed row remains; no deletion or merge reconciliation
Token loses access or a parent is deselectedExisting rows remain in ClickHouse
Source changes during offset paginationA scan may miss or repeat entities; it is not a provider snapshot
Run stops after some batchesLoaded batches remain visible; the affected stream restarts its collection at offset zero
Older source data loaded laterLater ingestion time wins; no source-version ordering
Complete change history neededRaw tables reconcile observations and do not form a permanent change log
Meetings, call recordings, transcripts, emails, files, comments, or sequences neededNo readers are included for these resources
Historical attribute values or separate option/status catalogs neededNo separate requests are made for these resources
Continuous sync or OAuth lifecycle neededSupply scheduling or authentication lifecycle outside this integration

Deleting or deselecting a source entity does not remove it from a view. Applications requiring an exact current snapshot must add a deliberate reconciliation process. A failed or permission-limited scan must not be used as evidence that unseen source records were deleted.

FileResponsibility
config.tsSource identity, destination names, explicit object/list references, page sizes
client.tsAuthentication, response validation, single-parent lookup, pagination, row identity, request pacing
sources/objects.ts, sources/object-attributes.tsObject and attribute readers with their raw table schemas
sources/records.tsRecord reader, raw records table, people/company/deal views
sources/lists.ts, sources/list-attributes.ts, sources/entries.tsList resource readers with their raw table schemas
sources/notes.ts, sources/tasks.ts, sources/members.tsOne reader and raw table schema per resource
tests/attio.test.ts, tests/fixtures.tsOptional installed tests and mocked API responses
pipeline.tsPer-type/per-list streams, resource tags, retries, batching, concurrency
index.tsExports that make the pipeline and schema objects discoverable

Remove a stream entry from pipeline.ts to stop collecting a resource. Keep its schema export to preserve existing tables under migration management. Removing a schema export changes the desired database schema and can generate a drop operation; review that migration separately.

Edit the view SQL in sources/records.ts to expose custom fields, then generate and apply a reviewed migration. Existing raw payloads support new projections without another API fetch. Reader changes apply to subsequent ingestion runs. Revisit pacing when raising concurrency, and test modified readers with fixtures before scheduling them.

Installed files belong to the project. Reinstalling does not overwrite local edits or automatically merge a newer integration version. See provider templates for version pinning and updates.

Include the integration’s tests when installing it:

Terminal window
bunx chkit add attio --with-tests
bun test src/integrations/attio/tests/attio.test.ts

The tests use fixture responses, a mocked HTTP client, and an in-memory destination. They exercise reader behavior without Attio credentials or a ClickHouse server. Edit the fixtures and assertions alongside customized readers. With --path, adjust the test command to the chosen installation directory.

Repository-only packaging and live-database tests remain outside the installed test set. The manifest selects the portable tests explicitly.

Version 0.2.0

  • Give People, Companies, each configured object type, and each configured list independent ingestion streams using chkit pagination.
  • Group those resource streams in one installation pipeline and bind factory reader settings independently of setup-time storage definitions.
  • Make object and list selection explicit, with People and Companies enabled by default; replay interrupted mutable offset reads from the beginning of that stream.
  • Preserve raw tables, scoped row identities, and typed views while changing child stream IDs; include fixtures for stream independence and failure recovery.

Version 0.1.2

  • Consolidate schemas and readers into resource modules, include portable fixture tests, and document authentication, resources, views, and sync behavior.

Version 0.1.1

  • Add integration guide and logo metadata; distributed source is unchanged.

Version 0.1.0

  • Introduce raw CRM objects, attributes, records, lists, entries, notes, tasks, and workspace members, with People, Companies, and Deals views.