Try Attio free. Modern CRM with AI built in. 10% off →
Back to blog

What I learned building against the Attio REST API

·8 min read

I have been building a tool that gets data into Attio. You point it at a CSV or another CRM, it proposes a data model, and on approval it creates the objects, attributes, statuses, and records for you and links them together.

That meant writing against the Attio REST API for real, not just reading a record here and there. Creating schema, upserting thousands of records, resolving relationships, rolling the whole thing back if something breaks.

The API is good. It is consistent and it is honest about what it does. The parts that cost me time were not the documented happy path. They were the gaps you only find once you send a real request and read a real error. This is that list, written down so the next person building against Attio does not spend the same afternoons I did.

The MCP cannot create schema. The REST API can.

First thing worth saying, because it sends a lot of people down the wrong road.

The Attio MCP connector is great for reading and for writing records. It cannot create objects, attributes, or statuses. If your plan is to build structure programmatically, the MCP is a dead end and you will not find that out until you go looking for the tool that does it.

The REST API, with an API key or an OAuth token, can create all of it. Objects, custom attributes, select options, statuses, records, and the links between records. So the moment the task is "build a workspace from scratch," you are on the REST API, not the MCP.

Required fields that look optional

The create-attribute endpoint is where I lost the first afternoon.

You would expect a minimal attribute to need a name and a type and nothing else. It does not. The body wants description, is_multiselect, and config to be present on every request, even when they are empty. Send them as undefined and the request fails. They are not optional fields with sensible defaults. They are required fields that happen to allow empty values.

So the fix is to always send the full shape:

{
  "title": "Stage",
  "api_slug": "stage",
  "type": "status",
  "description": "",
  "is_multiselect": false,
  "config": {}
}

Once I built every attribute body from a template that always included those keys, the failures stopped. The lesson is small but it generalizes: with this API, "optional in the docs" does not always mean "omit it from the request."

Currency attributes need their config spelled out

Same shape of problem, one level deeper.

A currency attribute will not create with an empty config. It needs the currency spelled out:

{
  "type": "currency",
  "config": {
    "currency": {
      "default_currency_code": "USD",
      "display_type": "symbol"
    }
  }
}

Leave that out and you get a validation error, not a default. If you are building attributes generically from a mapping, currency is the type that breaks the generic path, so it needs a branch of its own.

Some attribute types you can make by hand but not by API

This one is a real constraint, not a quirk, and it changed my design.

You cannot create a custom domain or location attribute through the API. The API tells you they are not yet supported. They exist as built-in attributes on the standard objects, so a Company already has a domain, but you cannot add your own domain-typed or location-typed attribute to a custom object.

If your data model wants a custom domain field somewhere, the API will refuse to build it. The practical move is to down-map those to text and lose the special behavior, or to lean on the built-in version where one exists. Either way it is a decision you have to make at design time, because the API will not let you defer it.

Upsert is real, and particular

Attio has a genuine upsert, which is the thing you want for an idempotent import. It is a PUT with a query parameter:

PUT /v2/objects/{object}/records?matching_attribute={slug}

Note that matching_attribute is singular. You pick one attribute to match on, email for people, domain for companies, some designated unique attribute for a custom object.

The catch that bit me: the matching attribute's value has to be present in the record you send. If you upsert a person and match on email, but the record in the payload has no email, there is nothing to match against, so you get a new record instead of an update. Run that twice and you have duplicates. The matching attribute is not metadata sitting beside the record. It has to be a real value inside it.

Record references force a two-pass design

This is the one that shaped the architecture of the whole tool.

A record reference, a link from a Deal to a Company for example, is written as an array of targets:

{
  "company": [
    { "target_object": "companies", "target_record_id": "..." }
  ]
}

You need the target record's Attio ID to write the link. And you only get that ID after the target record exists. So you cannot create a Deal that points at a Company in the same breath if the Company is not in Attio yet.

The answer is two passes. Pass one creates every record and records a map from the source system's ID to the new Attio record ID. Pass two goes back over the records, looks up each reference in that map, and writes the links. Anything that points at a record that did not make it across becomes a visible skipped link, not a silent failure.

I did not set out to build a two-pass importer. The reference model decided that for me. If you are moving related data into Attio, plan for two passes from the start.

OAuth gives you a long-lived token and no refresh

The OAuth flow is refreshingly plain. You exchange the code at POST https://app.attio.com/oauth/token and you get back a long-lived access token. No refresh token. No expiry to track. You store the token and use it.

Scopes are set per app in the Attio dashboard, not requested in the URL. For building structure and writing records you need object_configuration:read-write and record_permission:read-write. Workspace identity comes from GET /v2/self, which is how you find out which workspace the token belongs to.

It is worth contrasting this with the other CRMs I connected the same tool to. HubSpot hands you an access token that dies in thirty minutes and a refresh token you have to rotate. Pipedrive gives you a per-company API domain you have to thread through every call. Attio's "here is a token, it keeps working" is the easy one. Enjoy it.

Pagination and idempotency are your job

Two smaller notes that you will hit at scale.

The list endpoints page. If you list objects or attributes and assume a single page, you are fine in a demo workspace and wrong in a real one past a hundred or so entities. Follow the cursor.

And nothing is idempotent for you. To make schema creation re-runnable, I list what exists first and create only what is missing, so a second run reports "created 0 entities" instead of erroring or duplicating. The API gives you the primitives. The "do this safely twice" part is on you to build.

The endpoints, in one place

For anyone who just wants the map, here is what I ended up calling, verified against a real workspace:

  • Create object: POST /v2/objects
  • Create attribute: POST /v2/objects/{object}/attributes, with record references configured through config.record_reference.allowed_objects
  • Create options and statuses: POST /v2/objects/{object}/attributes/{attribute}/options and .../statuses
  • Create record: POST /v2/objects/{object}/records
  • Upsert record: PUT /v2/objects/{object}/records?matching_attribute={slug}
  • Values by type: status is { "status": "..." }, currency is { "currency_value": ... }, record reference is { "target_object": "...", "target_record_id": "..." }

What I actually took away

The Attio API is well shaped. The object model is clean, the upsert is real, and OAuth stays out of your way. None of the friction was the API being bad.

The friction was the difference between the documented path and the path you walk when you are creating a whole workspace from a script. Required fields that read as optional. Attribute types the UI offers but the API does not. A reference model that quietly demands a two-pass importer. None of that is in the first page of the docs, and all of it shapes the code.

That is the same thing I keep relearning whenever I build on a platform. The happy path is the part everyone documents. The constraints are the part that decides your architecture. Reading them out of error messages one at a time is slow, so here they are written down, which is the version I wish I had found first.

Need help with your CRM?

Messy data, manual processes, or a CRM that doesn't fit? Let's talk.

Book a call