# Sync contacts to Salesforce with Nylas

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

Activity capture in Salesforce only works when the people on an email or meeting already exist as Contact or Lead records. If the sender isn't in the CRM, the activity has nothing to attach to and falls on the floor. This recipe keeps Salesforce populated by syncing a user's address book from any connected provider into Contact and Lead records, then upserting on every change so the match rate stays high.

The read side is one Nylas call that returns the same contact schema for Google, Microsoft, iCloud, Yahoo, IMAP, and EWS. The write side is the Salesforce sObject REST API. The glue is the `contact.updated` webhook, which fires when a contact changes so you re-upsert one record instead of re-scanning the whole book.

This page is about the contact records themselves. If you want email and meetings logged as Salesforce Tasks, see [Export email data to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/). For the generic any-CRM version, see [Sync Google Contacts into a CRM](/docs/cookbook/contacts/sync-google-contacts-crm/).

## How does contact sync feed Salesforce activity capture?

Activity capture attaches an email or event to a Contact or Lead by matching the email address on the activity to the `Email` field on a record. Sync exists to guarantee that record is there first. You read each contact once with `GET /v3/grants/{grant_id}/contacts`, upsert it into Salesforce keyed on email, and the match rate climbs because no sender is missing.

The pipeline has three stages: read from Nylas, decide Contact or Lead, write to Salesforce. The read costs 1 request and returns up to 30 contacts per page by default across all 6 providers. A typical sales rep's address book holds 500 to 2,000 contacts, so a first sync is a handful of pages, not a long-running job. Each stage stays independent so one bad record never blocks the batch.

## How do I read contacts from Nylas?

List a grant's contacts with a single `GET /v3/grants/{grant_id}/contacts` call. The endpoint returns one unified contact schema across all 6 providers, so the same mapping function handles a Google account and an Exchange account without branching. The default page size is 30 contacts. Raise it with `limit` up to the provider maximum, then follow `next_cursor` to page through the remaining records.

The request below reads the first page. Each contact object carries 6 fields Salesforce wants, `emails`, `given_name`, `surname`, `company_name`, `job_title`, and `phone_numbers`, mapping straight onto a Contact or Lead.

````bash [readContacts-Request]

curl --compressed --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json'

````

````json [readContacts-Response]

{
  "request_id": "1",
  "data": [
    {
      "birthday": "1960-12-31",
      "company_name": "Nylas",
      "emails": [
        {
          "type": "work",
          "email": "leyah.miller@example.com"
        },
        {
          "type": "home",
          "email": "leyah@example.com"
        }
      ],
      "given_name": "Leyah",
      "grant_id": "<NYLAS_GRANT_ID>",
      "groups": [{ "id": "starred" }, { "id": "friends" }],
      "id": "<CONTACT_ID>",
      "im_addresses": [
        {
          "type": "jabber",
          "im_address": "jabber_at_leyah"
        },
        {
          "type": "msn",
          "im_address": "leyah.miller"
        }
      ],
      "job_title": "Software Engineer",
      "manager_name": "Nyla",
      "middle_name": "Allison",
      "nickname": "Allie",
      "notes": "Loves ramen",
      "object": "contact",
      "office_location": "123 Main Street",
      "phone_numbers": [
        {
          "type": "work",
          "number": "+1-555-555-5555"
        },
        {
          "type": "home",
          "number": "+1-555-555-5556"
        }
      ],
      "physical_addresses": [
        {
          "type": "work",
          "street_address": "123 Main Street",
          "postal_code": "94107",
          "state": "CA",
          "country": "US",
          "city": "San Francisco"
        },
        {
          "type": "home",
          "street_address": "123 Main Street",
          "postal_code": "94107",
          "state": "CA",
          "country": "US",
          "city": "San Francisco"
        }
      ],
      "picture_url": "https://example.com/picture.jpg",
      "source": "address_book",
      "surname": "Miller",
      "web_pages": [
        {
          "type": "work",
          "url": "<WEBPAGE_URL>"
        },
        {
          "type": "home",
          "url": "<WEBPAGE_URL>"
        }
      ]
    }
  ],
  "next_cursor": "2"
}


````

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

import Nylas from "nylas";

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

async function fetchContacts() {
  try {
    const identifier = "<NYLAS_GRANT_ID>";
    const contacts = await nylas.contacts.list({
      identifier,
      queryParams: {},
    });

    console.log("Recent Contacts:", contacts);
  } catch (error) {
    console.error("Error fetching drafts:", error);
  }
}

fetchContacts();


````

````python [readContacts-Python SDK]

from nylas import Client

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

grant_id = "<NYLAS_GRANT_ID>"

contacts = nylas.contacts.list(
  grant_id,
)

print(contacts)

````

The `next_cursor` value in the response is the page token for the next request. When it's absent, you've read the whole address book. For the filter parameters like `email`, `source`, and `group`, see the [Nylas Contacts API guide](/docs/cookbook/contacts/contacts-api-guide/).

## How do I map a Nylas contact to a Salesforce Contact?

Map the Nylas contact fields onto Salesforce's standard Contact fields, splitting the name and picking a primary email. Salesforce stores names as `FirstName` and `LastName`, so `given_name` and `surname` map directly. Email is the upsert key, so always pick one address even when a contact has both a work and home entry.

The table is the full mapping across 7 fields. Every Salesforce field listed is a standard field on the Contact object, so no custom setup is needed beyond one external ID field for upsert.

| Nylas contact field | Salesforce Contact field | Note |
| --- | --- | --- |
| `given_name` | `FirstName` | |
| `surname` | `LastName` | required by Salesforce |
| `emails[0].email` | `Email` | upsert match key |
| `company_name` | `Account.Name` | resolve or link separately |
| `job_title` | `Title` | |
| `phone_numbers[0].number` | `Phone` | |
| `id` | `Nylas_Contact_Id__c` | custom external ID field |

`LastName` is the one field Salesforce requires on a Contact. Some address-book entries are a company with no surname, so fall back to the email local part when `surname` is empty. That single guard prevents the most common write failure in a first sync.

## Should a contact become a Salesforce Contact or a Lead?

Salesforce splits people into two objects: a Lead is an unqualified prospect not yet tied to an account, and a Contact is a person linked to an Account record. Route each synced person by whether they belong to a known company. If the email domain matches an existing Account, create a Contact. Otherwise create a Lead so sales can qualify it.

The Lead object requires 2 fields, `LastName` and `Company`, where Contact requires only `LastName`. Use the contact's `company_name` for `Lead.Company`, and fall back to the email domain when it's blank, because a Lead write fails without that field. The decision below runs once per contact before the write.

````python [routeContact-Python]
def route(contact, known_account_domains):
    email = contact.emails[0].email if contact.emails else ""
    domain = email.split("@")[-1].lower()
    if domain in known_account_domains:
        return "Contact"
    return "Lead"
````

This split is the biggest difference from a generic CRM sync, where every person lands in one object. Keep the routing rule simple at first. You can move a Lead to a Contact later through Salesforce's standard lead conversion, which preserves the activity history attached to the record.

## How do I upsert a contact into Salesforce without duplicates?

Upsert keyed on an external ID so a re-sync updates the existing record instead of creating a second one. Salesforce's upsert REST resource is `PATCH /services/data/vXX.0/sobjects/{SObject}/{externalIdField}/{value}`, which creates the record when no match exists and updates it when one does. Each upsert is 1 request that covers both cases, and you key on a custom field holding the Nylas contact `id` for a stable, provider-independent match.

The call below upserts a Contact on a `Nylas_Contact_Id__c` external ID field. Salesforce returns 1 of 2 status codes, 201 when it created a record and 204 when it updated one, so you can count new versus changed records from the status code alone. Replace `v60.0` with your org's API version.

````python [upsertContact-Python]


def upsert_contact(sf_instance, sf_token, nylas_id, fields):
    url = (
        f"{sf_instance}/services/data/v60.0/sobjects/"
        f"Contact/Nylas_Contact_Id__c/{nylas_id}"
    )
    r = requests.patch(
        url,
        headers={"Authorization": f"Bearer {sf_token}"},
        json=fields,  # FirstName, LastName, Email, Title, Phone
    )
    r.raise_for_status()
    return r.status_code  # 201 created, 204 updated
````

````js [upsertContact-Node.js]
async function upsertContact(sfInstance, sfToken, nylasId, fields) {
  const url =
    `${sfInstance}/services/data/v60.0/sobjects/` +
    `Contact/Nylas_Contact_Id__c/${nylasId}`;
  const res = await fetch(url, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${sfToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(fields), // FirstName, LastName, Email, Title
  });
  if (!res.ok) throw new Error(`Salesforce ${res.status}`);
  return res.status; // 201 created, 204 updated
}
````

Keying on the Nylas `id` beats keying on email because a person can change their email address while the contact `id` stays fixed. If you'd rather match on email, Salesforce also supports `PATCH /sobjects/Contact/Email/{value}` when `Email` is marked as an external ID. New records you create directly instead of upserting use `POST /services/data/v60.0/sobjects/Contact/`.

## How do I keep Salesforce current with contact.updated webhooks?

Subscribe to the `contact.updated` trigger so Salesforce re-upserts a single record the moment a contact changes, instead of re-reading the whole address book on a timer. Nylas sends a webhook with the changed contact's grant and `id`, you fetch that one contact, and you run the same upsert. One changed contact costs 2 requests total, 1 read plus 1 write.

The `contact.updated` trigger is a real Nylas notification type and fires for create and update events on the contact object. The handler below pulls the contact and upserts it. Verify the webhook signature before trusting the payload, covered in [Verify webhook signatures](/docs/cookbook/use-cases/build/verify-webhook-signatures/).

````python [webhookHandler-Python]
@app.post("/webhooks/nylas")
def handle(payload):
    if payload["type"] != "contact.updated":
        return "", 200
    obj = payload["data"]["object"]
    contact = nylas.contacts.find(obj["grant_id"], obj["id"]).data
    fields = to_salesforce(contact)        # apply the mapping table
    upsert_contact(SF_INSTANCE, SF_TOKEN, contact.id, fields)
    return "", 200
````

Webhook-driven upserts keep the match rate high without a polling loop hammering both APIs every few minutes. For the full subscription setup and retry handling, see [Get real-time updates with webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/). To sync from one provider end to end, see [List and sync contacts](/docs/cookbook/contacts/list-and-sync-contacts/).

## Things to know about Salesforce contact sync

A few Salesforce behaviors shape how you build this, and most surface only after the first few hundred records.

- **Duplicate rules can block creation.** Salesforce's standard duplicate rules reject a new Contact or Lead when a fuzzy match exists, returning a `DUPLICATES_DETECTED` error. Upserting on the Nylas `id` external ID sidesteps the rule for known records; for genuinely new ones, send `Sforce-Duplicate-Rule-Header: allowSave=true` to override.
- **API limits are per 24 hours, not per call.** A Developer Edition org allows 15,000 REST calls per 24 hours, and Enterprise scales with licenses. A 2,000-contact first sync uses about 2,000 PATCH calls, so batch the initial load and let webhooks handle the steady state of a few writes per day.
- **Use a dedicated integration user.** Give the connected app a profile with create and edit on Contact and Lead only. Don't run as a System Administrator, because a sync bug with admin rights can touch far more than contact records.
- **Lead and Contact don't share an ID space.** A person synced as a Lead, then converted, gets a new Contact ID. Store the Nylas `id` on both objects so a converted person still matches on re-sync.
- **Field-level security applies.** If the integration user's profile hides `Title` or `Phone`, the upsert silently drops those fields. Check the profile's field permissions when a mapped value never lands.

## What's next

- [Export email data to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/): log email as Salesforce Tasks
- [Sync Google Contacts into a CRM](/docs/cookbook/contacts/sync-google-contacts-crm/): the generic any-CRM version
- [Salesforce REST API: upsert by external ID](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_upsert.htm): official Salesforce reference
- [Nylas Contacts API guide](/docs/cookbook/contacts/contacts-api-guide/): filters, groups, and field selection