API Documentation

Build powerful integrations with our RESTful API. Everything you need to get started.

Quick Start

Base URL

https://your-account.graycrm.io/api/v1

Authentication

Include your API key in the Authorization header as a Bearer token. All requests must include Content-Type: application/json.

curl https://your-account.graycrm.io/api/v1/contacts \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json"

Authentication & Scopes

API keys have scoped permissions. When creating an API key, select only the scopes your integration needs.

Available Scopes

contacts:read
contacts:write
properties:read
properties:write
tags:read
tags:write
notes:read
notes:write
activities:read
activities:write
flags:read
flags:write
custom_attributes:read
custom_attributes:write
audit_events:read
batch:write
search:read

Rate Limiting

API requests are rate-limited based on your subscription plan. Rate limits reset daily at midnight UTC.

Sandbox

1,000

requests/day

Starter

100,000

requests/day

Professional

1M

requests/day

Rate Limit Headers

Every API response includes rate limit information in the headers.

X-RateLimit-Limit: 100000
X-RateLimit-Remaining: 99876
X-RateLimit-Reset: 1738281600

Pagination

List endpoints return paginated results. Use the page and per_page parameters to navigate. Default is 25 items per page.

GET /api/v1/contacts?page=2&per_page=50
{
  "data": [...],
  "pagination": {
    "current_page": 2,
    "total_pages": 10,
    "total_count": 487,
    "per_page": 50
  }
}

Idempotency

Send an Idempotency-Key header on POST requests to safely retry failed requests without creating duplicates. The key can be any unique string (UUID recommended). Responses are cached for 24 hours.

curl -X POST https://your-account.graycrm.io/api/v1/contacts \
  -H "Authorization: Bearer your_api_key" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -H "Content-Type: application/json" \
  -d '{"contact": {"first_name": "Jane"}}'

API Status

Check API health status. No authentication required.

GET /api/v1/status
{
  "status": "ok",
  "version": "v1",
  "timestamp": "2026-02-10T12:00:00Z"
}

Contacts

Manage contacts and people. Requires contacts:read or contacts:write scope.

List Contacts

Supports pagination, Ransack filtering, and tag/flag query params.

GET /api/v1/contacts
Param Description
tag Filter by tag name(s). Comma-separated for multiple: ?tag=vip,hot-lead
tag_mode and (default) or or. Controls multi-tag matching.
flag[key] Filter by flag key: ?flag[key]=needs_review
flag[value] Optional. Filter by flag value: ?flag[key]=status&flag[value]=pending
attr[key] Filter by custom attribute key: ?attr[key]=vip_level
attr[value] Optional. Filter by attribute value: ?attr[key]=vip_level&attr[value]=gold
app_tag Filter to records enrolled in your app: ?app_tag=app:my_app. See App-Tag Segregation.
scope Set to account to bypass app_tag filter for discovery: ?app_tag=app:my_app&scope=account

Get Contact

Returns contact with emails, phones, tags, flags, and custom attributes.

GET /api/v1/contacts/:id

Create Contact

Create a new contact. Supports nested emails and phones via contact_emails_attributes and contact_phones_attributes.

POST /api/v1/contacts
{
  "contact": {
    "first_name": "Jane",
    "last_name": "Doe",
    "company": "Acme Corp",
    "source_detail": "website signup",
    "contact_emails_attributes": [
      {
        "email": "jane@example.com",
        "label": "work",
        "primary": true
      }
    ],
    "contact_phones_attributes": [
      {
        "phone": "+1-555-0100",
        "label": "mobile",
        "primary": true
      }
    ]
  }
}

Update Contact

PATCH /api/v1/contacts/:id

Delete Contact

Soft delete. Returns 204 No Content.

DELETE /api/v1/contacts/:id

Contact Emails

Manage email addresses for contacts. Requires contacts:write scope.

List Contact Emails

GET /api/v1/contacts/:contact_id/contact_emails

Create Contact Email

POST /api/v1/contacts/:contact_id/contact_emails
{
  "contact_email": {
    "email": "jane.work@example.com",
    "label": "work",
    "primary": false
  }
}

Update Contact Email

PATCH /api/v1/contacts/:contact_id/contact_emails/:id

Delete Contact Email

DELETE /api/v1/contacts/:contact_id/contact_emails/:id

Contact Phones

Manage phone numbers for contacts. Requires contacts:write scope.

List Contact Phones

GET /api/v1/contacts/:contact_id/contact_phones

Create Contact Phone

POST /api/v1/contacts/:contact_id/contact_phones
{
  "contact_phone": {
    "phone": "+1-555-0200",
    "label": "home",
    "primary": false
  }
}

Update Contact Phone

PATCH /api/v1/contacts/:contact_id/contact_phones/:id

Delete Contact Phone

DELETE /api/v1/contacts/:contact_id/contact_phones/:id

Properties

Manage physical addresses and locations linked to contacts. Requires properties:read or properties:write scope.

List Properties

Supports pagination, Ransack filtering, and tag/flag query params.

GET /api/v1/properties
Param Description
tag Filter by tag name(s). Comma-separated for multiple: ?tag=vip,hot-lead
tag_mode and (default) or or. Controls multi-tag matching.
flag[key] Filter by flag key: ?flag[key]=needs_review
flag[value] Optional. Filter by flag value: ?flag[key]=status&flag[value]=pending
attr[key] Filter by custom attribute key: ?attr[key]=lot_size
attr[value] Optional. Filter by attribute value: ?attr[key]=lot_size&attr[value]=large
app_tag Filter to records enrolled in your app: ?app_tag=app:my_app. See App-Tag Segregation.
scope Set to account to bypass app_tag filter for discovery: ?app_tag=app:my_app&scope=account

Get Property

Returns property with tags, flags, and custom attributes.

GET /api/v1/properties/:id

Create Property

POST /api/v1/properties
{
  "property": {
    "street": "123 Main St",
    "city": "San Francisco",
    "state": "CA",
    "zip": "94102",
    "country": "US",
    "latitude": 37.7749,
    "longitude": -122.4194,
    "place_id": "ChIJIQBpAG2ahYAR_6128GcTUEo",
    "source_detail": "MLS import"
  }
}

Update Property

PATCH /api/v1/properties/:id

Delete Property

Soft delete. Returns 204 No Content.

DELETE /api/v1/properties/:id

Contact-Property Links

Link contacts to properties with optional role. Requires both contacts:write and properties:write scopes.

List Links for Contact

GET /api/v1/contacts/:contact_id/contact_properties

Create Link

Link a contact to a property. Also available at /api/v1/properties/:property_id/contact_properties with contact_property[contact_id].

POST /api/v1/contacts/:contact_id/contact_properties
{
  "contact_property": {
    "property_id": "550e8400-e29b-41d4-a716-446655440000",
    "role": "owner"  // owner | tenant | manager | other
  }
}

Remove Link

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/contact_properties/:id

Tags

Manage tags as standalone resources. Requires tags:read or tags:write scope.

List Tags

Returns all tags in your account.

GET /api/v1/tags

Get Tag

GET /api/v1/tags/:id

Create Tag

POST /api/v1/tags
{
  "tag": {
    "name": "vip"
  }
}

Update Tag

Rename a tag. All taggings are updated automatically.

PATCH /api/v1/tags/:id

Delete Tag

Deletes tag and all taggings. Returns 204 No Content.

DELETE /api/v1/tags/:id

Taggings

Assign and remove tags from contacts or properties. Requires tags:write scope. Note: The nested route is /taggings, not /tags.

List Taggings

Returns all tags assigned to a contact or property. Also available on /api/v1/properties/:property_id/taggings.

GET /api/v1/contacts/:contact_id/taggings

Add Tag

Add a tag by name (finds or creates) or by ID. Also available on properties.

POST /api/v1/contacts/:contact_id/taggings
{
  "tagging": {
    "tag_name": "vip"
  }
}

// OR

{
  "tagging": {
    "tag_id": "550e8400-e29b-41d4-a716-446655440000"
  }
}

Remove Tag

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/taggings/:id

Custom Attributes

Store arbitrary key-value data on contacts or properties. Requires custom_attributes:read or custom_attributes:write scope.

List Custom Attributes

Also available on /api/v1/properties/:property_id/custom_attributes.

GET /api/v1/contacts/:contact_id/custom_attributes

Create Custom Attribute

POST /api/v1/contacts/:contact_id/custom_attributes
{
  "custom_attribute": {
    "key": "subscription_tier",
    "value": "premium"
  }
}

Update Custom Attribute

PATCH /api/v1/contacts/:contact_id/custom_attributes/:id

Delete Custom Attribute

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/custom_attributes/:id

Flags

Boolean flags for workflow management. Requires flags:read or flags:write scope.

List Flags

Also available on /api/v1/properties/:property_id/flags.

GET /api/v1/contacts/:contact_id/flags

Create Flag

POST /api/v1/contacts/:contact_id/flags
{
  "flag": {
    "key": "high_value",
    "value": true
  }
}

Update Flag

PATCH /api/v1/contacts/:contact_id/flags/:id

Claim Flag

Claim a pending flag (workflow action).

POST /api/v1/contacts/:contact_id/flags/:id/claim

Delete Flag

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/flags/:id

Notes

Add notes to contacts or properties. Requires notes:read or notes:write scope.

List Notes

Ordered by most recent. Also available on /api/v1/properties/:property_id/notes.

GET /api/v1/contacts/:contact_id/notes

Create Note

POST /api/v1/contacts/:contact_id/notes
{
  "note": {
    "body": "Called customer to discuss pricing."
  }
}

Update Note

PATCH /api/v1/contacts/:contact_id/notes/:id

Delete Note

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/notes/:id

Activities

Track interactions and events. Requires activities:read or activities:write scope.

List Activities

Ordered by most recent. Also available on /api/v1/properties/:property_id/activities.

GET /api/v1/contacts/:contact_id/activities

Create Activity

POST /api/v1/contacts/:contact_id/activities
{
  "activity": {
    "activity_type": "phone_call",
    "subject": "Intro call",
    "body": "Discussed pricing and timeline.",
    "occurred_at": "2026-02-10T14:30:00Z"
  }
}

Update Activity

PATCH /api/v1/contacts/:contact_id/activities/:id

Delete Activity

Returns 204 No Content.

DELETE /api/v1/contacts/:contact_id/activities/:id

Audit Events

Complete audit trail of all changes. Requires audit_events:read scope.

List All Audit Events

Paginated and filterable via Ransack.

GET /api/v1/audit_events

Get Audit Event

GET /api/v1/audit_events/:id

List Audit Events for Contact

GET /api/v1/contacts/:contact_id/audit_events

List Audit Events for Property

GET /api/v1/properties/:property_id/audit_events

Batch Operations

Process up to 100 operations in a single request. Requires batch:write scope plus appropriate resource scopes.

Batch Request

Valid methods: POST, PATCH, DELETE. Valid resources: contacts, properties. Max 100 operations per request.

POST /api/v1/batch
{
  "operations": [
    {
      "method": "POST",
      "resource": "contacts",
      "body": {
        "first_name": "Jane"
      }
    },
    {
      "method": "PATCH",
      "resource": "contacts",
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "body": {
        "last_name": "Doe"
      }
    },
    {
      "method": "DELETE",
      "resource": "properties",
      "id": "660e8400-e29b-41d4-a716-446655440002"
    }
  ]
}

Webhooks

Subscribe to real-time events. Manage webhooks via the web UI.

Available Events

contact.created
contact.updated
contact.deleted
property.created
property.updated
property.deleted
note.created
note.updated
note.deleted
tagging.created
tagging.deleted
flag.created
flag.updated
flag.deleted
custom_attribute.created
custom_attribute.updated
custom_attribute.deleted
contact_property.created
contact_property.deleted

Example Webhook Payload

{
  "event": "contact.updated",
  "timestamp": "2026-02-10T12:00:00Z",
  "account_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "record": { ... },
    "changes": {
      "first_name": ["John", "Jane"]
    }
  }
}

App-Tag Segregation

Partition contacts and properties into per-application views using tags. Each integration can maintain its own slice of your CRM data without interfering with others.

How It Works

Create a tag using the app: naming convention (e.g., app:lawn_care). Tag contacts and properties to enroll them in your app. Then pass the app_tag query parameter on list endpoints to filter results to only enrolled records.

Workflow

1

Create your app tag

POST /api/v1/tags with name app:my_app

2

Discover records

GET /api/v1/contacts?app_tag=app:my_app&scope=account to search the full account

3

Enroll records

POST /api/v1/contacts/:id/taggings with tag_name app:my_app

4

Query enrolled records

GET /api/v1/contacts?app_tag=app:my_app returns only enrolled contacts

5

Unenroll records

DELETE /api/v1/contacts/:id/taggings/:tagging_id to remove the app tag

Query Parameters

Param Description
app_tag Tag name for app-level filtering. Convention: app:your_app_name
scope Set to account to bypass the app_tag filter and search all records (discovery mode)

Examples

List only enrolled contacts:

GET /api/v1/contacts?app_tag=app:lawn_care

Discover all contacts in the account (bypass filter):

GET /api/v1/contacts?app_tag=app:lawn_care&scope=account

Enroll a contact:

POST /api/v1/contacts/:id/taggings
{
  "tagging": {
    "tag_name": "app:lawn_care"
  }
}

Works with properties too:

GET /api/v1/properties?app_tag=app:lawn_care

Multiple Apps, One Account

Each integration uses its own app tag. A contact can be enrolled in multiple apps simultaneously. Tags are lightweight and do not duplicate data. All standard filters (Ransack, flags, attributes) work alongside app_tag.

Error Handling

All errors return a consistent JSON structure with HTTP status codes.

HTTP Status Codes

200 OK - Request succeeded
201 Created - Resource created successfully
204 No Content - Delete succeeded, no body returned
400 Bad Request - Invalid parameters
401 Unauthorized - Invalid or missing API key
403 Forbidden - Insufficient scope or account suspended
404 Not Found - Resource does not exist
422 Unprocessable Entity - Validation errors
429 Too Many Requests - Rate limit exceeded
500 Internal Server Error - Something went wrong on our end

Error Response Format

{
  "error": {
    "type": "validation_error",
    "message": "First name is required",
    "details": {
      "first_name": ["can't be blank"]
    }
  }
}

Ready to Build?

Get started with our free Sandbox plan and start integrating today

Get Started Free