Every Attio API error returns a consistent JSON body. This skill covers the real error codes, response format, and proven solutions for each.
All errors from https://api.attio.com/v2 return this structure:
{
"status_code": 429,
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded, please try again later"
}
Fields: status_code (HTTP status), type (error category), code (specific code), message (human-readable).
invalid_request{ "status_code": 400, "type": "invalid_request_error", "code": "invalid_request", "message": "..." }
Common causes and fixes:
| Message pattern | Cause | Fix |
|---|---|---|
Invalid value for attribute |
Wrong type for attribute slug | Check attribute type with GET /v2/objects/{obj}/attributes |
Cannot query historic values |
Used history param on unsupported type | Remove show_historic for that attribute |
Missing required field |
Required attribute not provided | Check is_required on attribute definition |
Invalid filter format |
Malformed filter object | Use shorthand { "email": "x" } or verbose { "$and": [...] } |
Diagnostic:
# List attributes to verify types
curl -s https://api.attio.com/v2/objects/people/attributes \
-H "Authorization: Bearer ${ATTIO_API_KEY}" \
| jq '.data[] | {slug: .api_slug, type: .type, required: .is_required}'
authentication_error{ "status_code": 401, "type": "authentication_error", "code": "invalid_api_key", "message": "..." }
| Cause | Fix |
|---|---|
Missing Authorization header |
Add Authorization: Bearer sk_... |
| Token revoked or deleted | Generate new token in Attio dashboard |
| Malformed header | Ensure format is Bearer <token> (one space, no quotes) |
Diagnostic:
# Verify token works
curl -s -o /dev/null -w "%{http_code}" \
https://api.attio.com/v2/objects \
-H "Authorization: Bearer ${ATTIO_API_KEY}"
# Should return 200
insufficient_scopes{ "status_code": 403, "type": "authorization_error", "code": "insufficient_scopes",
"message": "Token requires 'record_permission:read-write' scope" }
| Operation | Required scopes |
|---|---|
| List/get records | object_configuration:read + record_permission:read |
| Create/update records | object_configuration:read + record_permission:read-write |
| List entries | object_configuration:read + record_permission:read + list_entry:read |
| Create/update entries | Above + list_entry:read-write |
| Create notes | note:read-write + object_configuration:read + record_permission:read |
| List tasks | task:read + object_configuration:read + record_permission:read + user_management:read |
| Manage webhooks | webhook:read-write |
Fix: Edit token in Settings > Developers > Access tokens, add missing scope, save. No need to regenerate.
not_found{ "status_code": 404, "type": "not_found_error", "code": "not_found", "message": "..." }
| Cause | Fix |
|---|---|
| Wrong object slug | Verify with GET /v2/objects -- use api_slug field |
| Invalid record_id | Record may have been deleted or merged |
| Wrong list slug | Verify with GET /v2/lists |
| Typo in endpoint path | Check path starts with /v2/ |
conflictOccurs when creating a record with a value that conflicts with an existing unique attribute (e.g., duplicate email or domain).
Fix: Use PUT (assert) instead of POST to upsert:
// Assert: create or update matching record
await client.put("/objects/people/records", {
data: {
values: {
email_addresses: ["existing@example.com"],
name: [{ first_name: "Updated", last_name: "Name" }],
},
},
});
validation_error| Message pattern | Cause | Fix |
|---|---|---|
Invalid email address |
Malformed email string | Validate email format before sending |
Invalid phone number |
Not E.164 format | Prefix with country code: +14155551234 |
Unknown attribute |
Attribute slug does not exist | List attributes first |
Invalid record reference |
target_record_id doesn't exist | Verify record exists first |
rate_limit_exceeded{
"status_code": 429,
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded, please try again later"
}
Attio uses a sliding window algorithm with a 10-second window. The Retry-After response header contains a date (usually the next second).
Immediate fix:
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const waitMs = retryAfter
? new Date(retryAfter).getTime() - Date.now()
: 1000;
await new Promise((r) => setTimeout(r, Math.max(waitMs, 100)));
// Retry the request
}
See attio-rate-limits for full backoff and queue patterns.
Rare, but Attio may reduce rate limits during incidents. Always implement retry for 5xx.
Check: status.attio.com
#!/bin/bash
echo "=== Attio Diagnostic ==="
echo -n "Auth: "
curl -s -o /dev/null -w "%{http_code}" \
https://api.attio.com/v2/objects \
-H "Authorization: Bearer ${ATTIO_API_KEY}"
echo ""
echo -n "Status page: "
curl -s https://status.attio.com/api/v2/status.json | jq -r '.status.description'
echo "Objects:"
curl -s https://api.attio.com/v2/objects \
-H "Authorization: Bearer ${ATTIO_API_KEY}" \
| jq -r '.data[].api_slug' 2>/dev/null || echo "FAILED"
import { AttioApiError } from "./client";
async function handleAttioError(err: AttioApiError): Promise<void> {
switch (err.statusCode) {
case 401: throw new Error("Attio auth failed -- check ATTIO_API_KEY");
case 403: throw new Error(`Missing scope: ${err.message}`);
case 404: console.warn("Resource not found, may have been deleted"); break;
case 409: console.warn("Conflict -- use PUT to upsert instead"); break;
case 429: /* handled by retry wrapper */ break;
default: throw err;
}
}
For evidence collection, see attio-debug-bundle. For retry patterns, see attio-rate-limits.