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?
Section titled “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 rather than reproducing it. For a provider-agnostic version of the same pattern, see Sync Google Contacts into a CRM.
List contacts from the source mailbox
Section titled “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.
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>'{ "request_id": "1", "data": [ { "id": "<CONTACT_ID>", "grant_id": "<NYLAS_GRANT_ID>", "given_name": "Leyah", "surname": "Miller", "company_name": "Nylas", "job_title": "Software Engineer", "phone_numbers": [{ "type": "work", "number": "+1-555-555-5555" }], "object": "contact" } ], "next_cursor": "<NEXT_CURSOR>"}import Nylas from "nylas";
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 },});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.
Map the contact fields to the Zoho CRM module
Section titled “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 if your org added custom fields; the 6 fields above are standard on every Zoho CRM account.
Upsert contacts into Zoho CRM
Section titled “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.
{ "data": [ { "First_Name": "Leyah", "Last_Name": "Miller", "Phone": "+1-555-555-5555", "Account_Name": "Nylas", "Title": "Software Engineer" } ], "duplicate_check_fields": ["Email"]}import requests
# api_domain comes from the OAuth token response, e.g. https://www.zohoapis.comdef 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. Batch in groups of 100 to stay efficient.
Route the sync to the correct Zoho data center
Section titled “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.
Keep contacts in sync with webhooks
Section titled “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.
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.
What’s next
Section titled “What’s next”- Sync Google Contacts into a CRM for the provider-agnostic version of this pattern, with HubSpot and Salesforce mapping.
- List and sync contacts to filter by email or inbox source and paginate the full address book.
- Nylas Contacts API guide for the unified contact schema and a recipe for every contact task.
- Real-time webhooks to subscribe to triggers and verify webhook signatures.