Skip to main content

Your Implementation Required

These endpoints must be implemented on your server

Overview

The sync and update endpoints allow bidirectional data flow between Enginy and your CRM:
EndpointPurpose
POST /contacts/syncFind existing contacts and optionally return properties to Enginy
PUT /contactsUpdate contacts that already exist in your CRM
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.

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

Email (Most Common)

Match by email field - the most reliable identifier for contacts

LinkedIn URL

Match by linkedinUrl - useful for B2B contacts

Phone Number

Match by phone or mobilePhone - normalize formats first

Composite Key

Combine multiple fields (email + company) for higher accuracy

Sync Contacts

Request

POST /contacts/sync
Content-Type: application/json
X-API-Key: your-api-key

Request Body

Enginy sends all contacts it wants to sync:
{
  "contacts": [
    {
      "externalId": "lead-123",
      "email": "[email protected]",
      "firstName": "Sarah",
      "lastName": "Chen",
      "phone": "+1-555-0123",
      "company": "TechStartup",
      "title": "VP of Engineering",
      "linkedinUrl": "https://linkedin.com/in/sarahchen"
    },
    {
      "externalId": "lead-456",
      "email": "[email protected]",
      "firstName": "Mike",
      "lastName": "Johnson",
      "company": "Enterprise Corp",
      "title": "CTO"
    },
    {
      "externalId": "lead-789",
      "email": "[email protected]",
      "firstName": "New",
      "lastName": "Prospect"
    }
  ]
}

Response

Return only contacts that exist in your CRM:
{
  "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"
      }
    }
  ]
}
Contact lead-789 is not in the response because it doesn’t exist in your CRM. Only return matches.

Example Implementation

Here’s how to implement the matching logic:
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')
}

Update Contacts

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

Request

PUT /contacts
Content-Type: application/json
X-API-Key: your-api-key

Request Body

{
  "contacts": [
    {
      "crmId": "contact_8f3k2j",
      "email": "[email protected]",
      "firstName": "Sarah",
      "lastName": "Chen",
      "phone": "+1-555-9999",
      "company": "TechStartup (Acquired)",
      "title": "CTO"
    }
  ]
}
Every contact in an update request has a crmId because it was returned during a previous sync.

Response

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

Example Implementation

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 })
})

Properties You Can Sync Back

When you return properties in your response, Enginy will store these values on the contact. Common properties to sync:
PropertyTypeDescription
emailstringContact’s email address
phonestringContact’s phone number
mobilePhonestringContact’s mobile phone number
leadScorenumberYour CRM’s lead scoring value
lifecycleStagestringLead, MQL, SQL, Opportunity, Customer
lastActivityDatestringISO 8601 date of last activity
ownerstringAssigned sales rep ID or name
dealValuenumberAssociated deal/opportunity value
tagsarrayCRM tags or labels
customField_*anyAny custom fields you want to sync
You can return any properties you want. Enginy will store them as custom fields on the contact.

Handling Null Values

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.
To prevent accidentally clearing data in Genesy, follow these guidelines:
Your CRM returnsResult in Genesy
"email": "[email protected]"Email is updated to [email protected]
"email": nullEmail is cleared (set to null)
Field not included in responseEmail 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.
// 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):
FieldTypeDescription
externalIdstringEnginy’s internal contact ID (use for mapping)
emailstringPrimary email address
firstNamestringFirst name
lastNamestringLast name
fullNamestringFull name
phonestringPhone number
mobilePhonestringMobile phone
companystringCompany name
titlestringJob title
linkedinUrlstringLinkedIn profile URL
locationstringLocation/address
citystringCity
statestringState/region
countrystringCountry
industrystringIndustry
websitestringPersonal website
biostringBiography/description
tagsarrayTags/labels
customFieldsobjectAny 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

{
  "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 FieldTypeExample ValueDescription
campaignEngagementStatusstringMessage Replied (2/3) - LINKEDINCurrent engagement status
campaignSequenceStatusstringOngoingSequence status: Not Started, Ongoing, Finished, Replied
campaignSequenceDetailsstringLinkedin Message, Email, Linkedin MessageSequence step types
campaignOpensstring5Number of email opens
campaignClicksstring2Number of link clicks
campaignOpenAnalysisstringYes (5) or NoWhether emails were opened
campaignClickAnalysisstringYes (2) or NoWhether links were clicked
campaignReplyAnalysisstringYes (1) or NoWhether contact replied
activitiesstringConnection Request Sent, Message Sent (LINKEDIN)Comma-separated activity log
sendersstringJohn Smith, Jane DoeCampaign senders
campaignsstringQ1 Outreach, Product LaunchCampaign 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
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).

Implementation Tip

Make sure your PUT /contacts handler stores unknown fields rather than ignoring them:
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:
{
  "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:
{
  "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

{
  "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 FieldTypeExample ValueDescription
campaignEngagementStatusstringMessage Replied (2/3) - LINKEDINCurrent engagement status
campaignSequenceStatusstringOngoingSequence status: Not Started, Ongoing, Finished, Replied
campaignSequenceDetailsstringLinkedin Message, Email, Linkedin MessageSequence step types
campaignOpensstring5Number of email opens
campaignClicksstring2Number of link clicks
campaignOpenAnalysisstringYes (5) or NoWhether emails were opened
campaignClickAnalysisstringYes (2) or NoWhether links were clicked
campaignReplyAnalysisstringYes (1) or NoWhether contact replied
activitiesstringConnection Request Sent, Message Sent (LINKEDIN)Comma-separated activity log
sendersstringJohn Smith, Jane DoeCampaign senders
campaignsstringQ1 Outreach, Product LaunchCampaign 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
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).

Implementation Tip

Make sure your PUT /contacts handler stores unknown fields rather than ignoring them:
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

GitHub Repository

See a complete working implementation of contacts sync and update endpoints, including engagement field handling and activity display.