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, nine checkpointed full-scan streams, and three SQL views for people, companies, and deals. Each complete cycle reads the selected resources through the Attio REST API and updates their latest observed versions in ClickHouse.

PropertyIncluded behavior
Registry appattio (v0.1.3)
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 full default integration uses all the scopes below. 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”

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

export interface AttioConfig {
sourceId: string
database: string
tablePrefix: string
objects: readonly string[] | undefined
lists: readonly string[] | undefined
pageSize: number
notesPageSize: number
}
export const attioConfig: AttioConfig = {
sourceId: 'attio.primary',
database: 'default',
tablePrefix: 'attio',
objects: undefined,
lists: undefined,
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
objectsObject API slugs or UUIDs; undefined discovers all accessible objects; [] selects none
listsList API slugs or UUIDs; undefined discovers all accessible lists; [] selects none
pageSizePositive integer, default 500, for attributes, records, entries, and tasks
notesPageSizePositive integer up to 50, default 50, for notes

For example, set objects: ['people', 'companies'] to restrict object metadata, object attributes, and records. List selection similarly controls lists, list attributes, and entries. An unknown or inaccessible configured slug or ID fails visibly. Notes, tasks, and members remain workspace-wide; their readers need separate changes to narrow their coverage.

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 resource has its own stream and 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
Records (records)attio_records_rawCurrent records for every selected object, IDs, creation time, Attio URL, and the complete values object with all returned attribute arraysPOST/objects/{object_id}/records/query
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
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
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 records stream discovers objects through the objects API. It is not restricted to the three objects with packaged views. People, companies, deals, and custom objects all land in the same records table when accessible and selected. A users object, when present, contains CRM records; workspace members are collected separately.

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. Object and list placeholders are resolved to IDs by discovery before the child request runs.

ResourcePagination and filters
objectsSingle collection request, then configured object selection
object_attributeslimit and offset query parameters; show_archived=true
recordslimit and offset in JSON body; no record filter or custom sort
listsSingle collection request, then configured list selection
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 start at offset zero, advance by the number of returned entities, and stop when a page is shorter than the requested limit. Each stream discovers its own parents, so running records alone does not depend on the objects stream having run first. The pipeline’s stream array does not establish dependencies.

BehaviorDetails
SyncFull scan cycles retain the latest observed raw entities. Interrupted parent collections resume from the frozen object/list set after the last completely loaded parent; unfinished offset scans restart at zero. A completed cycle starts fresh discovery and full reads on the next run.
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 reader discovers accessible parents where needed, applies the configured selection, and fetches the collection 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.

All nine streams save validated scan-cycle checkpoints. A fresh cycle discovers and freezes the selected object/list IDs and slugs. After every parent is fully fetched and loaded, an empty completion marker flushes preceding rows and commits that parent to the frontier. After interruption, the reader skips completed parents and restarts the unfinished parent at offset zero. It resumes the frozen set before performing new discovery. New parents are included in the next full cycle.

Notes, tasks, and members use workspace-wide scans. Their unfinished collections restart at offset zero; terminal completion is persisted even when a collection is empty. Objects and lists likewise record completion after their returned rows load. A completed cycle starts a fresh full read on the next run, so edits remain observable. These checkpoints are scan boundaries, not a modification watermark.

ingest status exposes the cycle, phase, frozen parent set, and completed parents. A selection change rejects the saved scope rather than reusing a frontier for different objects/lists. Restore the configuration or use a new source identity for a deliberate fresh scan. A removed or inaccessible frozen parent fails visibly. Child response workspace and parent IDs are checked against frozen discovery identity before any rows are published. Existing installations that have no checkpoint begin the first checkpointed cycle without a state migration.

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 reuse the records stream; 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 the records stream:

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. With the default source ID, --tag stream:attio.primary.records selects the same records stream; a custom sourceId changes that derived tag.

Inspect the run’s per-stream outcomes and error messages. status reports committed scan-cycle checkpoints rather than complete run history. Keep the ingestion journal as durable operational state. After fixing a token, permission, or destination issue, rerun ingest run; the Attio reader resumes its frozen parent set and restarts any unfinished offset collection.

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. Date-range backfill flags do not turn these full-read readers into historical or incremental readers; implementing those behaviors 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; completed parents are skipped and unfinished offset collections restart
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, parent selection, page sizes
client.tsAuthentication, response validation, pagination, parent-completion boundaries, row identity, request pacing
checkpoints.tsValidated scan cycles, frozen parent sets, and selection scope
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.tsEnabled 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.