# Sync contacts to Zoho CRM with Nylas

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/sync-contacts-zoho/

Zoho CRM runs across seven separate data centers, and a contact that exists in the US org at `zohoapis.com` is invisible to the EU org at `zohoapis.eu`. That regional split is the first thing that trips up a contacts sync. This recipe pulls a connected mailbox's address book through the Nylas Contacts API, then upserts each person into the right Zoho data center using the v6 records API.

The Nylas side gives you one normalized contact schema across Google, Microsoft, and the other providers, so you write the Zoho mapping once. The Zoho side does the deduplication for you through its upsert endpoint, keyed on email. Wire a `contact.updated` webhook on top and the sync stays current without a nightly cron job.

## How does the Nylas-to-Zoho contact sync work?

The sync has two halves: read from Nylas, write to Zoho CRM. You list contacts with `GET /v3/grants/{grant_id}/contacts`, which returns one schema across 6 providers, map each record's fields to Zoho's Contacts module, then send a `POST` to Zoho's `/crm/v6/Contacts/upsert` endpoint. Zoho matches on email and either creates or updates the record, so you never write duplicates.

The flow stays one-directional unless you add webhooks. For an initial backfill, paginate the full address book once. After that, subscribe to the `contact.updated` trigger so each change in the source mailbox pushes a single upsert instead of re-reading everything. Zoho's upsert accepts up to 100 records per request, which sets your batch size.

| Step | System | Endpoint |
| --- | --- | --- |
| List contacts | Nylas | `GET /v3/grants/{grant_id}/contacts` |
| Map fields | Your code | given_name → First_Name, etc. |
| Upsert records | Zoho CRM | `POST /crm/v6/Contacts/upsert` |
| Stay in sync | Nylas | `contact.updated` webhook |

This recipe stays Nylas-centric: the read path is fully worked, and the Zoho calls link out to [Zoho's v6 API reference](https://www.zoho.com/crm/developer/docs/api/v6/insert-records.html) rather than reproducing it. For a provider-agnostic version of the same pattern, see [Sync Google Contacts into a CRM](/docs/cookbook/contacts/sync-google-contacts-crm/).

## List contacts from the source mailbox

Start by reading the connected address book. The `GET /v3/grants/{grant_id}/contacts` request returns contacts in one unified schema across 6 providers (Google, Microsoft, iCloud, Yahoo, EWS, and IMAP), so the Zoho mapping you write works for every account. The default page size is 30 contacts and the maximum is 200 per request, set through the `limit` query parameter.

````bash [listContacts-Request]
curl --compressed --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts?limit=100' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
````

````json [listContacts-Response]
{
  "request_id": "1",
  "data": [
    {
      "id": "<CONTACT_ID>",
      "grant_id": "<NYLAS_GRANT_ID>",
      "given_name": "Leyah",
      "surname": "Miller",
      "company_name": "Nylas",
      "job_title": "Software Engineer",
      "emails": [{ "type": "work", "email": "leyah.miller@example.com" }],
      "phone_numbers": [{ "type": "work", "number": "+1-555-555-5555" }],
      "object": "contact"
    }
  ],
  "next_cursor": "<NEXT_CURSOR>"
}
````

````js [listContacts-Node.js SDK]


const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>" });

const { data: contacts } = await nylas.contacts.list({
  identifier: "<NYLAS_GRANT_ID>",
  queryParams: { limit: 100 },
});
````

````python [listContacts-Python SDK]
from nylas import Client

nylas = Client("<NYLAS_API_KEY>", "<NYLAS_API_URI>")

contacts = nylas.contacts.list(
    "<NYLAS_GRANT_ID>",
    query_params={"limit": 100},
)
````

The response gives you `given_name`, `surname`, `company_name`, `job_title`, plus `emails` and `phone_numbers` arrays. You don't need to learn each provider's native contact format, since the schema is identical whether the grant is a Gmail or Outlook account. For filtering by email or inbox source before syncing, see [List and sync contacts](/docs/cookbook/contacts/list-and-sync-contacts/).

## Map the contact fields to the Zoho CRM module

Zoho's Contacts module names fields differently from the Nylas schema, and one field is non-negotiable. `Last_Name` is the only system-defined mandatory field in the Contacts module, so a contact with no surname needs a fallback value or Zoho rejects the record with a `MANDATORY_NOT_FOUND` error. Map the source fields onto Zoho's API names like this:

| Nylas field | Zoho Contacts API name | Notes |
| --- | --- | --- |
| `given_name` | `First_Name` | optional |
| `surname` | `Last_Name` | required; fall back to email local-part if empty |
| `emails[0].email` | `Email` | upsert key |
| `phone_numbers[0].number` | `Phone` | optional |
| `company_name` | `Account_Name` | links to an Account record |
| `job_title` | `Title` | optional |

The `Email` field is what Zoho uses to detect duplicates, so it doubles as your upsert key. Field API names come from Zoho's [Fields Metadata API](https://www.zoho.com/crm/developer/docs/api/v6/field-meta.html) if your org added custom fields; the 6 fields above are standard on every Zoho CRM account.

## Upsert contacts into Zoho CRM

Send mapped contacts to Zoho's upsert endpoint so existing people get updated instead of duplicated. The `POST {api-domain}/crm/v6/Contacts/upsert` request takes a `data` array of up to 100 records per request and a `duplicate_check_fields` array that tells Zoho which field identifies a match. Set it to `["Email"]` to key on email address, matching the upsert key from your field mapping.

````json [zohoUpsert-Request body]
{
  "data": [
    {
      "First_Name": "Leyah",
      "Last_Name": "Miller",
      "Email": "leyah.miller@example.com",
      "Phone": "+1-555-555-5555",
      "Account_Name": "Nylas",
      "Title": "Software Engineer"
    }
  ],
  "duplicate_check_fields": ["Email"]
}
````

````python [zohoUpsert-Python]


# api_domain comes from the OAuth token response, e.g. https://www.zohoapis.com
def upsert_contacts(api_domain, access_token, records):
    res = requests.post(
        f"{api_domain}/crm/v6/Contacts/upsert",
        headers={"Authorization": f"Zoho-oauthtoken {access_token}"},
        json={"data": records[:100], "duplicate_check_fields": ["Email"]},
    )
    res.raise_for_status()
    # action is "insert" for new records, "update" for matched ones
    return [row["action"] for row in res.json()["data"]]
````

Each request body carries 6 fields per contact, and each response item returns an `action` of `insert` or `update` so you can log how many records were new versus matched. The upsert call counts against your daily API credit limit, which starts at 25,000 credits for Free and Standard editions per Zoho's [API limits documentation](https://www.zoho.com/crm/developer/docs/api/v6/api-limits.html). Batch in groups of 100 to stay efficient.

## Route the sync to the correct Zoho data center

A Zoho access token only works against the data center where the user's org lives, and there are 7 of them. The OAuth token response returns 5 fields, one of which is `api_domain`, telling you exactly which base URL to call, so never hardcode `zohoapis.com`. Read `api_domain` from the token exchange and use it for every records request.

| Region | Accounts OAuth domain | API base URL |
| --- | --- | --- |
| US | `https://accounts.zoho.com` | `https://www.zohoapis.com` |
| EU | `https://accounts.zoho.eu` | `https://www.zohoapis.eu` |
| India | `https://accounts.zoho.in` | `https://www.zohoapis.in` |
| Australia | `https://accounts.zoho.com.au` | `https://www.zohoapis.com.au` |
| Japan | `https://accounts.zoho.jp` | `https://www.zohoapis.jp` |
| China | `https://accounts.zoho.com.cn` | `https://www.zohoapis.com.cn` |
| Canada | `https://accounts.zohocloud.ca` | `https://www.zohoapis.ca` |

You exchange your authorization code for tokens at `{accounts_domain}/oauth/v2/token`, requesting the `ZohoCRM.modules.contacts.ALL` scope for read and write access to the Contacts module. The full domain list and redirect rules are in Zoho's [multi-DC documentation](https://www.zoho.com/crm/developer/docs/api/v6/multi-dc.html).

## Keep contacts in sync with webhooks

After the initial backfill, a `contact.updated` webhook keeps Zoho current without re-reading the whole address book. Nylas emits 2 contact triggers: `contact.updated` and `contact.deleted`. There's no `contact.created` trigger, so a brand-new contact arrives as a `contact.updated` notification. Subscribe to both, and each change in the source mailbox delivers one payload you turn into a single Zoho upsert or delete.

````bash [contactWebhook-Create subscription]
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["contact.updated", "contact.deleted"],
    "webhook_url": "https://your-app.com/webhooks/nylas",
    "description": "Sync contact changes to Zoho CRM"
  }'
````

The payload names the `grant_id` and contact `id` that changed, so your handler fetches that one contact and upserts it. Respond with a `200` status within 10 seconds or Nylas retries the delivery. For signature verification and the full event lifecycle, see [Real-time webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/).

## What's next

- [Sync Google Contacts into a CRM](/docs/cookbook/contacts/sync-google-contacts-crm/) for the provider-agnostic version of this pattern, with HubSpot and Salesforce mapping.
- [List and sync contacts](/docs/cookbook/contacts/list-and-sync-contacts/) to filter by email or inbox source and paginate the full address book.
- [Nylas Contacts API guide](/docs/cookbook/contacts/contacts-api-guide/) for the unified contact schema and a recipe for every contact task.
- [Real-time webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) to subscribe to triggers and verify webhook signatures.