> ## Documentation Index
> Fetch the complete documentation index at: https://docs.enginy.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync & Update Guide

> Sync contacts and receive updates from your CRM

<Card title="Your Implementation Required" icon="triangle-exclamation" color="#f59e0b">
  These endpoints must be implemented on **your server**
</Card>

## Overview

The sync and update endpoints allow bidirectional data flow between Enginy and your CRM:

| Endpoint              | Purpose                                                           |
| --------------------- | ----------------------------------------------------------------- |
| `POST /contacts/sync` | Find existing contacts and optionally return properties to Enginy |
| `PUT /contacts`       | Update contacts that already exist in your CRM                    |

<Info>
  **Key Principle**: All matching logic lives in **your CRM**, not Enginy. You decide how to identify contacts
  (by email, LinkedIn URL, phone, etc.) and what data to sync back.
</Info>

***

## How Matching Works

When Enginy sends contacts to sync, your CRM should:

1. **Look up each contact** using your preferred identifier(s)
2. **Return matches** with your CRM's internal ID (`crmId`)
3. **Optionally return properties** you want synced back to Enginy

### Common Matching Strategies

<CardGroup cols={2}>
  <Card title="Email (Most Common)" icon="envelope">
    Match by `email` field - the most reliable identifier for contacts
  </Card>

  <Card title="LinkedIn URL" icon="linkedin">
    Match by `linkedinUrl` - useful for B2B contacts
  </Card>

  <Card title="Phone Number" icon="phone">
    Match by `phone` or `mobilePhone` - normalize formats first
  </Card>

  <Card title="Composite Key" icon="key">
    Combine multiple fields (email + company) for higher accuracy
  </Card>
</CardGroup>

***

## Sync Contacts

### Request

```http theme={null}
POST /contacts/sync
Content-Type: application/json
X-API-Key: your-api-key
```

### Request Body

Enginy sends all contacts it wants to sync:

```json theme={null}
{
  "contacts": [
    {
      "externalId": "lead-123",
      "email": "sarah.chen@techstartup.io",
      "firstName": "Sarah",
      "lastName": "Chen",
      "phone": "+1-555-0123",
      "company": "TechStartup",
      "title": "VP of Engineering",
      "linkedinUrl": "https://linkedin.com/in/sarahchen"
    },
    {
      "externalId": "lead-456",
      "email": "mike.johnson@enterprise.com",
      "firstName": "Mike",
      "lastName": "Johnson",
      "company": "Enterprise Corp",
      "title": "CTO"
    },
    {
      "externalId": "lead-789",
      "email": "new.prospect@unknown.com",
      "firstName": "New",
      "lastName": "Prospect"
    }
  ]
}
```

### Response

Return **only contacts that exist** in your CRM:

```json theme={null}
{
  "results": [
    {
      "externalId": "lead-123",
      "crmId": "contact_8f3k2j",
      "properties": {
        "leadScore": 85,
        "lifecycleStage": "MQL",
        "lastActivityDate": "2024-01-15T14:30:00Z",
        "owner": "sales-rep-1"
      }
    },
    {
      "externalId": "lead-456",
      "crmId": "contact_9x7m4n",
      "properties": {
        "leadScore": 45,
        "lifecycleStage": "Lead"
      }
    }
  ]
}
```

<Note>
  Contact `lead-789` is not in the response because it doesn't exist in your CRM. Only return matches.
</Note>

### Example Implementation

Here's how to implement the matching logic:

<CodeGroup>
  ```javascript Node.js theme={null}
  app.post('/contacts/sync', async (req, res) => {
    const { contacts } = req.body
    const results = []

    for (const contact of contacts) {
      // Strategy 1: Match by email (most common)
      let existing = await db.contacts.findOne({
        email: contact.email?.toLowerCase(),
      })

      // Strategy 2: Fallback to LinkedIn URL
      if (!existing && contact.linkedinUrl) {
        existing = await db.contacts.findOne({
          linkedinUrl: normalizeLinkedInUrl(contact.linkedinUrl),
        })
      }

      // If found, return the match with properties to sync back
      if (existing) {
        results.push({
          externalId: contact.externalId,
          crmId: existing.id,
          properties: {
            leadScore: existing.leadScore,
            lifecycleStage: existing.lifecycleStage,
            lastActivityDate: existing.lastActivityDate,
            owner: existing.ownerId,
          },
        })
      }
      // If not found, don't include in results
    }

    res.json({ results })
  })

  function normalizeLinkedInUrl(url) {
    // Remove trailing slashes, www, etc.
    return url?.toLowerCase().replace(/\/$/, '').replace('www.linkedin.com', 'linkedin.com')
  }
  ```

  ```python Python theme={null}
  @app.post("/contacts/sync")
  async def sync_contacts(data: ContactsSync):
      results = []

      for contact in data.contacts:
          # Strategy 1: Match by email
          existing = await db.contacts.find_one({
              "email": contact.email.lower() if contact.email else None
          })

          # Strategy 2: Fallback to LinkedIn URL
          if not existing and contact.linkedinUrl:
              existing = await db.contacts.find_one({
                  "linkedinUrl": normalize_linkedin_url(contact.linkedinUrl)
              })

          # If found, return the match
          if existing:
              results.append({
                  "externalId": contact.externalId,
                  "crmId": existing["id"],
                  "properties": {
                      "leadScore": existing.get("leadScore"),
                      "lifecycleStage": existing.get("lifecycleStage"),
                      "lastActivityDate": existing.get("lastActivityDate"),
                  }
              })

      return {"results": results}
  ```
</CodeGroup>

***

## Update Contacts

After syncing, Enginy knows which contacts have CRM IDs. The update endpoint receives **only contacts that were previously matched**.

### Request

```http theme={null}
PUT /contacts
Content-Type: application/json
X-API-Key: your-api-key
```

### Request Body

```json theme={null}
{
  "contacts": [
    {
      "crmId": "contact_8f3k2j",
      "email": "sarah.chen@techstartup.io",
      "firstName": "Sarah",
      "lastName": "Chen",
      "phone": "+1-555-9999",
      "company": "TechStartup (Acquired)",
      "title": "CTO"
    }
  ]
}
```

<Tip>Every contact in an update request has a `crmId` because it was returned during a previous sync.</Tip>

### Response

```json theme={null}
{
  "results": [
    {
      "crmId": "contact_8f3k2j",
      "success": true,
      "properties": {
        "lastUpdated": "2024-01-20T10:30:00Z"
      }
    }
  ]
}
```

### Example Implementation

<CodeGroup>
  ```javascript Node.js theme={null}
  app.put('/contacts', async (req, res) => {
    const { contacts } = req.body
    const results = []

    for (const contact of contacts) {
      try {
        // Find by crmId (always present in update requests)
        const existing = await db.contacts.findById(contact.crmId)

        if (!existing) {
          results.push({
            crmId: contact.crmId,
            success: false,
            error: 'Contact not found',
          })
          continue
        }

        // Update the contact with new data
        // Your CRM decides which fields to update
        await db.contacts.updateOne(
          { _id: contact.crmId },
          {
            $set: {
              email: contact.email,
              firstName: contact.firstName,
              lastName: contact.lastName,
              phone: contact.phone,
              company: contact.company,
              title: contact.title,
              updatedAt: new Date(),
              updatedBy: 'Enginy-sync',
            },
          },
        )

        results.push({
          crmId: contact.crmId,
          success: true,
          properties: {
            lastUpdated: new Date().toISOString(),
          },
        })
      } catch (error) {
        results.push({
          crmId: contact.crmId,
          success: false,
          error: error.message,
        })
      }
    }

    res.json({ results })
  })
  ```

  ```python Python theme={null}
  @app.put("/contacts")
  async def update_contacts(data: ContactsUpdate):
      results = []

      for contact in data.contacts:
          try:
              existing = await db.contacts.find_one({"_id": contact.crmId})

              if not existing:
                  results.append({
                      "crmId": contact.crmId,
                      "success": False,
                      "error": "Contact not found"
                  })
                  continue

              await db.contacts.update_one(
                  {"_id": contact.crmId},
                  {"$set": {
                      "email": contact.email,
                      "firstName": contact.firstName,
                      "lastName": contact.lastName,
                      "phone": contact.phone,
                      "company": contact.company,
                      "title": contact.title,
                      "updatedAt": datetime.utcnow(),
                  }}
              )

              results.append({
                  "crmId": contact.crmId,
                  "success": True,
              })
          except Exception as e:
              results.append({
                  "crmId": contact.crmId,
                  "success": False,
                  "error": str(e)
              })

      return {"results": results}
  ```
</CodeGroup>

***

## Properties You Can Sync Back

When you return `properties` in your response, Enginy will store these values on the contact. Common properties to sync:

| Property           | Type   | Description                           |
| ------------------ | ------ | ------------------------------------- |
| `email`            | string | Contact's email address               |
| `phone`            | string | Contact's phone number                |
| `mobilePhone`      | string | Contact's mobile phone number         |
| `leadScore`        | number | Your CRM's lead scoring value         |
| `lifecycleStage`   | string | Lead, MQL, SQL, Opportunity, Customer |
| `lastActivityDate` | string | ISO 8601 date of last activity        |
| `owner`            | string | Assigned sales rep ID or name         |
| `dealValue`        | number | Associated deal/opportunity value     |
| `tags`             | array  | CRM tags or labels                    |
| `customField_*`    | any    | Any custom fields you want to sync    |

<Info>You can return any properties you want. Enginy will store them as custom fields on the contact.</Info>

### Handling Null Values

<Warning>
  **Important**: When Genesy syncs properties back from your CRM, fields that are returned as `null` (or not
  returned at all) will **clear the existing value** in Genesy. This includes core fields like `email` and
  `phone`.
</Warning>

To prevent accidentally clearing data in Genesy, follow these guidelines:

| Your CRM returns               | Result in Genesy                       |
| ------------------------------ | -------------------------------------- |
| `"email": "john@example.com"`  | Email is updated to `john@example.com` |
| `"email": null`                | Email is **cleared** (set to null)     |
| Field not included in response | Email is **cleared** (set to null)     |

**Best Practice**: Only include fields in `properties` that you explicitly want to sync back. If you don't want to modify a field, **don't include it** in the properties object.

```javascript theme={null}
// Good: Only return fields you want to update
results.push({
  externalId: contact.externalId,
  crmId: existing.id,
  properties: {
    leadScore: existing.leadScore,
    lifecycleStage: existing.lifecycleStage,
    // Don't include email/phone if you don't want to overwrite Genesy's values
  },
})

// Bad: Including null values will clear data in Genesy
results.push({
  externalId: contact.externalId,
  crmId: existing.id,
  properties: {
    email: existing.email, // If null, will clear email in Genesy!
    phone: existing.phone, // If null, will clear phone in Genesy!
  },
})
```

***

## Available Contact Fields

Enginy sends these fields (when available):

| Field          | Type   | Description                                    |
| -------------- | ------ | ---------------------------------------------- |
| `externalId`   | string | Enginy's internal contact ID (use for mapping) |
| `email`        | string | Primary email address                          |
| `firstName`    | string | First name                                     |
| `lastName`     | string | Last name                                      |
| `fullName`     | string | Full name                                      |
| `phone`        | string | Phone number                                   |
| `mobilePhone`  | string | Mobile phone                                   |
| `company`      | string | Company name                                   |
| `title`        | string | Job title                                      |
| `linkedinUrl`  | string | LinkedIn profile URL                           |
| `location`     | string | Location/address                               |
| `city`         | string | City                                           |
| `state`        | string | State/region                                   |
| `country`      | string | Country                                        |
| `industry`     | string | Industry                                       |
| `website`      | string | Personal website                               |
| `bio`          | string | Biography/description                          |
| `tags`         | array  | Tags/labels                                    |
| `customFields` | object | Any custom fields                              |

***

## Engagement Field Updates

When contacts engage with campaigns (reply, connect, click links, etc.), Enginy sends engagement updates through the same `PUT /contacts` endpoint. These updates contain **custom field names** configured by the user in their campaign settings.

### Example Engagement Update

```json theme={null}
{
  "contacts": [
    {
      "crmId": "contact_8f3k2j",
      "genesy_last_engaged_at": "2024-12-31T10:00:00Z"
    }
  ]
}
```

### Available Engagement Fields

Users can configure which fields to sync and what to name them. Here are all the fields Enginy can send:

| Enginy Field               | Type   | Example Value                                      | Description                                                      |
| -------------------------- | ------ | -------------------------------------------------- | ---------------------------------------------------------------- |
| `campaignEngagementStatus` | string | `Message Replied (2/3) - LINKEDIN`                 | Current engagement status                                        |
| `campaignSequenceStatus`   | string | `Ongoing`                                          | Sequence status: `Not Started`, `Ongoing`, `Finished`, `Replied` |
| `campaignSequenceDetails`  | string | `Linkedin Message, Email, Linkedin Message`        | Sequence step types                                              |
| `campaignOpens`            | string | `5`                                                | Number of email opens                                            |
| `campaignClicks`           | string | `2`                                                | Number of link clicks                                            |
| `campaignOpenAnalysis`     | string | `Yes (5)` or `No`                                  | Whether emails were opened                                       |
| `campaignClickAnalysis`    | string | `Yes (2)` or `No`                                  | Whether links were clicked                                       |
| `campaignReplyAnalysis`    | string | `Yes (1)` or `No`                                  | Whether contact replied                                          |
| `activities`               | string | `Connection Request Sent, Message Sent (LINKEDIN)` | Comma-separated activity log                                     |
| `senders`                  | string | `John Smith, Jane Doe`                             | Campaign senders                                                 |
| `campaigns`                | string | `Q1 Outreach, Product Launch`                      | Campaign names                                                   |

### Engagement Status Values

The `campaignEngagementStatus` field follows this progression:

1. `Added to Campaign` - Contact added but no action taken
2. `Connection Request Sent` - LinkedIn connection request sent
3. `Connection Accepted` - LinkedIn connection accepted
4. `Message Sent (1/3) - LINKEDIN` - First message sent (with type)
5. `Message Replied (1/3) - LINKEDIN` - Contact replied

<Info>
  Field names are user-configurable. Your CRM should accept **any** field name and store it appropriately
  (either as a known field or as a custom field).
</Info>

### Implementation Tip

Make sure your `PUT /contacts` handler stores unknown fields rather than ignoring them:

```javascript theme={null}
app.put('/contacts', async (req, res) => {
  const { contacts } = req.body
  const results = []

  for (const contact of contacts) {
    const { crmId, ...fieldsToUpdate } = contact

    // Store ALL fields, not just known ones
    await db.contacts.updateOne({ _id: crmId }, { $set: fieldsToUpdate })

    results.push({ crmId, success: true })
  }

  res.json({ results })
})
```

***

## Error Handling

### Partial Success

If some contacts fail, return successful results and errors separately:

```json theme={null}
{
  "results": [
    {
      "externalId": "lead-123",
      "crmId": "contact_8f3k2j"
    }
  ],
  "errors": [
    {
      "externalId": "lead-456",
      "error": "Duplicate email found - multiple contacts match"
    }
  ]
}
```

### Update Failures

For updates, indicate failure in the result:

```json theme={null}
{
  "results": [
    {
      "crmId": "contact_8f3k2j",
      "success": true
    },
    {
      "crmId": "contact_invalid",
      "success": false,
      "error": "Contact not found"
    }
  ]
}
```

***

## Engagement Field Updates

When contacts engage with campaigns (reply, connect, click links, etc.), Enginy sends engagement updates through the same `PUT /contacts` endpoint. These updates contain **custom field names** configured by the user in their campaign settings.

### Example Engagement Update

```json theme={null}
{
  "contacts": [
    {
      "crmId": "contact_8f3k2j",
      "genesy_engagement_status": "Message Replied (2/3) - LINKEDIN",
      "genesy_sequence_status": "Replied",
      "genesy_last_activity": "2024-12-31T10:00:00Z"
    }
  ]
}
```

### Available Engagement Fields

Users can configure which fields to sync and what to name them. Here are all the fields Enginy can send:

| Enginy Field               | Type   | Example Value                                      | Description                                                      |
| -------------------------- | ------ | -------------------------------------------------- | ---------------------------------------------------------------- |
| `campaignEngagementStatus` | string | `Message Replied (2/3) - LINKEDIN`                 | Current engagement status                                        |
| `campaignSequenceStatus`   | string | `Ongoing`                                          | Sequence status: `Not Started`, `Ongoing`, `Finished`, `Replied` |
| `campaignSequenceDetails`  | string | `Linkedin Message, Email, Linkedin Message`        | Sequence step types                                              |
| `campaignOpens`            | string | `5`                                                | Number of email opens                                            |
| `campaignClicks`           | string | `2`                                                | Number of link clicks                                            |
| `campaignOpenAnalysis`     | string | `Yes (5)` or `No`                                  | Whether emails were opened                                       |
| `campaignClickAnalysis`    | string | `Yes (2)` or `No`                                  | Whether links were clicked                                       |
| `campaignReplyAnalysis`    | string | `Yes (1)` or `No`                                  | Whether contact replied                                          |
| `activities`               | string | `Connection Request Sent, Message Sent (LINKEDIN)` | Comma-separated activity log                                     |
| `senders`                  | string | `John Smith, Jane Doe`                             | Campaign senders                                                 |
| `campaigns`                | string | `Q1 Outreach, Product Launch`                      | Campaign names                                                   |

### Engagement Status Progression

The `campaignEngagementStatus` field follows this progression:

1. `Added to Campaign` - Contact added but no action taken
2. `Connection Request Sent` - LinkedIn connection request sent
3. `Connection Accepted` - LinkedIn connection accepted
4. `Message Sent (1/3) - LINKEDIN` - First message sent (with channel type)
5. `Message Replied (1/3) - LINKEDIN` - Contact replied

<Info>
  Field names are user-configurable. Your CRM should accept **any** field name and store it appropriately
  (either as a known field or as a custom field).
</Info>

### Implementation Tip

Make sure your `PUT /contacts` handler stores unknown fields rather than ignoring them:

```javascript theme={null}
app.put('/contacts', async (req, res) => {
  const { contacts } = req.body
  const results = []

  for (const contact of contacts) {
    const { crmId, ...fieldsToUpdate } = contact

    // Store ALL fields, not just known ones
    await db.contacts.updateOne({ _id: crmId }, { $set: fieldsToUpdate })

    results.push({ crmId, success: true })
  }

  res.json({ results })
})
```

***

## Reference Implementation

<Card title="GitHub Repository" icon="github" href="https://github.com/Genesy-AI/custom-crm-example">
  See a complete working implementation of contacts sync and update endpoints, including engagement field
  handling and activity display.
</Card>
