Skip to content

Attio API field guide

This page collects the sharp edges of the Attio API, learned from running production automations against it: syncs, cleanup tools, dashboards, and agents, across multiple live fund workspaces. An API (application programming interface) is the doorway programs use to read and write a system’s data; everything on this page is about talking to Attio through that doorway rather than through its normal screens. Everything here was either hit in production or is verified against the source code of attio-cli and valentine.

You may never call this API yourself, but whoever automates your CRM will: your engineer, a contractor, or an AI assistant you point at the docs. If Attio is your firm’s single source of truth, this API is how every sync, dashboard, and agent reaches it, and each trap below has cost a real project an afternoon. Handing this page to whoever builds (or pasting it into the AI doing the building) is the cheapest way to skip those afternoons, and knowing the failure stories helps you ask better questions when an automation misbehaves. The official docs at developers.attio.com describe how things should work; this is the list of ways they surprise you.

Three basics before the traps. The base address for every request is https://api.attio.com/v2. Every request must include an API key (the password a program uses to act on your workspace) sent as Authorization: Bearer <key>. And those keys do not expire, though you can revoke them at any time.

All responses wrap their content in {"data": ...}, so a program must always unwrap .data before touching anything. Inside a record, every field value arrives as a list of typed value objects, even for fields that hold a single value, and each object’s internal shape depends on the field’s type. The response below shows one company record; notice how even the simple name is wrapped in layers of packaging.

{
"data": {
"id": { "workspace_id": "", "object_id": "", "record_id": "97f5397f-…" },
"created_at": "2026-03-14T09:21:07.000000000Z",
"web_url": "https://app.attio.com/…",
"values": {
"name": [{ "attribute_type": "text", "value": "Acme GmbH" }],
"domains": [{ "attribute_type": "domain", "domain": "acme.com", "root_domain": "acme.com" }],
"deal_stage": [{ "attribute_type": "status", "status": { "title": "Due diligence", "id": { "…": "" } } }],
"ebitda": [{ "attribute_type": "currency", "currency_value": 2500000 }]
}
}
}

The catch: the key holding the actual value is different for nearly every field type (“Acme GmbH” sits under value, but the stage title sits under status.title). This table is lifted from attio-cli’s value flattener (src/values.ts), which exists precisely because no single lookup works:

attribute_typeWhere the value actually is
text, number, checkbox, date, timestamp, ratingvalue
currencycurrency_valuenot value
selectoption.title (a full option object, not a string)
statusstatus.title (same)
domaindomain (with root_domain alongside)
email-addressemail_address
personal-namefull_name, or first_name + last_name
phone-numberoriginal_phone_number
locationlocality, region, country_code
record-referencetarget_object + target_record_id
actor-referencereferenced_actor_id
interactioninteraction_type + interacted_at

Writing is simpler than reading, which surprises people. The request wraps values in {"data": {"values": {...}}}, but you send plain values and the API adds the packaging itself. A dropdown field takes its option name as plain text; a multi-select takes a list of names. The request below writes three fields on a company.

{ "data": { "values": {
"name": "Acme GmbH",
"deal_stage": "Due diligence",
"sectors": ["Fintech", "B2B SaaS"]
} } }

Compare it with the response above and you see the asymmetry: what you write as "Due diligence" comes back wrapped in the full envelope. Every program consuming this API needs a small unwrapping layer like the table above; budget for it on day one.

Requests to an API carry a verb saying what kind of operation they are: GET normally means “read something” and POST normally means “create something.” Attio breaks with that convention for a practical reason: the requests that list or filter records need somewhere to put their filters, so they travel as POSTs.

OperationEndpoint
List/filter recordsPOST /objects/{object}/records/query
List/filter list entriesPOST /lists/{list}/entries/query
Full-text search across objectsPOST /objects/records/search
Single recordGET /objects/{object}/records/{record_id}
Notes for a recordGET /notes?parent_object=…&parent_record_id=…
Objects, lists, attributes, members, /selfplain GET

Query requests take {"filter": {...}, "sorts": [...], "limit": N, "offset": N}. Filters use operator keywords borrowed from the MongoDB database, such as {"name": {"$contains": "acme"}}, $eq (equals), $gte (greater than or equal), and $not_empty, combined with $and/$not. Simple equality can be written as shorthand ({"domains": "acme.com"}). Results come in pages, and you page by count-and-skip (limit and offset) with no bookmark to resume from: keep requesting until a short page comes back. attio-cli’s src/pagination.ts is a reference implementation.

One more shape trap: list entries are not records. A list entry (a record’s membership row in a list, which carries its own fields) returns its values under entry_values rather than values, its writes go in {"data": {"entry_values": {...}}}, its parent record is parent_record_id, and a list’s entry fields are a separate set from the parent object’s, fetched via GET /lists/{list}/attributes rather than the object’s field list.

A slug is the permanent internal name a field gets when it is created, and it is routinely nothing like the label you see on screen. Real examples from production workspaces: a field displayed as “Ticket” whose slug is target_raise; one displayed as “CA” whose slug is amount_invested; a migrated workspace whose Affinity ID landed under the auto-generated slug affinity_id_8. Guess from the visible label and you will filter on a field that does not exist, and a query filter on a nonexistent field fails or matches nothing rather than telling you why.

The fix is mechanical: list the fields before you touch them. The command below, using attio-cli, prints every field on the companies object with its real slug.

Terminal window
attio attributes list companies # GET /objects/companies/attributes
# → api_slug, title, type, is_required, is_unique, is_multiselect

Compare the title column (what you see on screen) against api_slug (what the API wants) and use only the latter in code. Valentine takes the defensive version of the same stance: its Attio connector relies only on standard built-in fields (name, domains, interaction signals) and treats any unknown field or object as “no match, never crash,” because a workspace’s custom setup cannot be assumed.

Select and status fields: objects out, titles in, IDs to manage

Section titled “Select and status fields: objects out, titles in, IDs to manage”

Select fields (pick one option from a set) and status fields (pick one stage from a funnel) behave three different ways depending on the operation:

  • Reading returns a full option object (option.title / status.title plus an ID), per the envelope table above.
  • Writing accepts the option’s name as plain text.
  • Managing options (adding, renaming, mapping name to ID for anything long-lived) goes through dedicated endpoints: …/attributes/{attribute}/options for selects and …/attributes/{attribute}/statuses for statuses. If you store option references anywhere durable, store IDs and keep a name-to-ID map, because names are one rename away from breaking you.

Two management gotchas hit while building an LP-fundraising pipeline for a VC fund via the API: creating a field requires a config object (pass an empty {} if you have nothing to configure, or the request fails with a 400 error), and the order of statuses cannot be set via the API; a stage funnel created programmatically comes out in creation order and must be reordered by dragging in the app.

Create, update, assert: POST vs PATCH vs PUT

Section titled “Create, update, assert: POST vs PATCH vs PUT”

Attio offers four ways to write a record, distinguished by their request verbs. PATCH means “update part of this record” and PUT means “replace this record’s values.” The difference matters most for multi-select fields, where one appends and the other overwrites:

CallSemanticsMultiselect behavior
POST /objects/{o}/recordsCreate, alwaysn/a
PATCH /objects/{o}/records/{id}Update by IDAppends values, keeps existing
PUT /objects/{o}/records/{id}Update by IDOverwrites — removes values you omit
PUT /objects/{o}/records?matching_attribute={slug}Assert (upsert): update the record whose {slug} matches, else createAppends, like assert generally

The assert endpoint is the workhorse for safe re-runs: match on a unique field (an email, a stripe_customer_id, an external ID) and re-running a sync updates the same records instead of duplicating them. That property is called idempotency, and it is the core requirement of automation safety. The same PATCH-appends versus PUT-overwrites split applies to list entries at /lists/{l}/entries/{id}.

Historic values: the CRM remembers more than it shows

Section titled “Historic values: the CRM remembers more than it shows”

A status field’s current value is only the surface; Attio keeps its full history, and you can retrieve it per record or list entry. The request below asks for every value one deal’s stage field has ever held.

GET /lists/{list}/entries/{entry_id}/attributes/deal_stage/values?show_historic=true
→ every value the attribute has held, each with active_from / active_until

The response lists each past stage with the dates it was active, which is what makes after-the-fact reconstruction possible. This is why reconciliation beats webhooks for stamping stage-transition dates: a scheduled job can reconstruct the true first-entry date for every stage a deal ever passed through, including transitions from before the job existed. The full pattern is in CRM as database.

A rate limit is the ceiling on how many requests an API will accept from one key before it starts refusing them. Attio enforces one per key and answers over-limit requests with error code 429 plus a Retry-After header saying when to try again (sometimes a number of seconds, sometimes a date; parse both, as attio-cli’s client does, otherwise backing off exponentially from 1 second over 3 retries).

The quirk that is not in the docs: under sustained load, Attio occasionally returns error code 401 (“bad credentials”) on a perfectly valid key. For an interactive tool the right response to a 401 is to stop immediately, and attio-cli does, because it usually means a real key problem. For an unattended job the right response is different: a production Stripe-revenue sync treats 401 as temporary alongside 429 and 5xx (server errors), retrying with a wait, and has run daily on that policy (the retry helper is shown in cron agents). Policy by context:

StatusInteractive CLIUnattended job
429Retry, honor Retry-AfterRetry, honor Retry-After
5xx / connection / timeoutFailRetry with backoff
401Fail fast (probably a real key problem)Retry with backoff (probably load)

You can list a workspace’s saved views, but you cannot create or modify them: views and dashboards are app-only. Anything that needs a programmatically produced “view” of the data should read the underlying list through the query endpoints and present it elsewhere, which is exactly the static-dashboard pattern in CRM as database. Attio’s in-app workflow automations are similarly invisible to the public API: you cannot list or inspect them, which matters when you are trying to work out what is writing to a field.

Notes: backdatable at creation, frozen after

Section titled “Notes: backdatable at creation, frozen after”

The notes API can create, fetch, list, and delete notes, but it has no way to edit one. Two consequences:

  • A note’s creation date can be set when it is created (“backdating,” explicitly supported for migrations) but never changed afterward. Redating an existing note means deleting it and recreating it with the desired date.
  • Any fix to a note’s content or title is also delete-and-recreate. Read content_plaintext off the note when you need its body; titles are plain text only.

The failure this enables at scale: a VC fund’s Affinity migration was partially re-imported, and because the re-import created notes, every copy got a fresh creation date. The result was thousands of duplicates distinguishable from their originals only by date and by which account created them, on a resource where nothing can be edited in place. The cleanup had to identify each redated pair and delete one side, backup taken first. Notes also record which API key authored them, which turned out to be the only reliable way to tell the two import waves apart, and is a good reason to give each automation its own key.