Skip to main content
This guide walks you through building a production-ready LinkedIn outreach sequence using the Edges API. You’ll learn how to:
  • Set up identities and connect LinkedIn accounts
  • Source and manage leads
  • Send connection requests and messages at scale
  • Handle replies, de-duplication, and errors
  • Respect rate limits and best practices
Recommended setup: Use Engagement Identities for outreach actions. They’re credit-free for connection requests, messages, and profile visits.

Section 1: Prerequisites & Key Concepts

Before building your sequence, familiarize yourself with these core concepts:
ConceptWhat it isDocumentation
WorkspaceIsolated environment with its own API key and identitiesCore Concepts
IdentityRepresents a user whose LinkedIn account will perform actionsAdd & Manage Identities
Engagement IdentitySpecial identity type with credit-free outreach actionsEngagement Identities
Identity Modesdirect, auto, managed - controls which account performs actionsIdentity Modes
Smart LimitsPer-action, per-identity daily limits to protect accountsLinkedIn Smart Limits
Execution Modeslive (sync), async (background), schedule (recurring)Execute Actions
CallbacksWebhook delivery for async/scheduled action resultsCallbacks

Identity Setup Checklist

1

Create an Engagement Identity

Use the Create Identity API with is_engagement: true
2

Connect LinkedIn

Use Connect Integration with cookies, email/password, or login links
3

Set up Webhooks

Monitor integration status changes like AUTH_EXPIRED. See LinkedIn Integration Webhooks

Section 2: Lead Sourcing

Leads can come from Edges search actions or external sources like CSV uploads.
// Search for leads using LinkedIn search
const searchResults = await fetch('https://api.edges.run/v1/actions/linkedin-search-people/run/live', {
  method: 'POST',
  headers: {
    'X-API-Key': EDGES_API_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    identity_mode: 'auto',
    input: {
      keywords: 'CTO SaaS',
      max_results: 100
    }
  })
});

const leads = await searchResults.json();
// Each result contains: linkedin_profile_url, full_name, headline, etc.
See Search LinkedIn People for all search parameters.

Option B: External Sources

Import leads from your CRM, CSV, or other sources. Required fields:
  • linkedin_profile_url (required) - e.g., https://www.linkedin.com/in/johndoe
  • full_name (recommended for personalization)
  • company_name (recommended for personalization)
Best practice: Store the immutable linkedin_profile_id once you have it. Profile URLs can change if users update their vanity URL.

Section 3: Get User’s LinkedIn Profile ID

The user’s linkedin_profile_id is needed to detect replies (comparing who sent the last message).
Good news: The linkedin_profile_id is already available in the integration’s meta field when you retrieve the integration details. No extra API call needed!
// Get linkedin_profile_id from integration meta (recommended)
async function getIntegrationProfileId(identityId, integration = 'linkedin') {
  const response = await fetch(
    `https://api.edges.run/v1/identities/${identityId}/integrations/${integration}`,
    {
      headers: { 'X-API-Key': EDGES_API_KEY }
    }
  );
  
  const data = await response.json();
  return data.meta?.linkedin_profile_id;  // Already available!
}

// Cache this when identity is created/connected
const userProfileId = await getIntegrationProfileId('identity_abc123');
See Get an Identity’s Integration for full response schema.
Alternative: If you need additional profile details (headline, company, etc.), you can use linkedin-me which returns full profile information.

Section 4: Database Schema (Pseudocode)

Track leads, conversations, and outreach history:
-- Leads table
CREATE TABLE leads (
  id UUID PRIMARY KEY,
  identity_id VARCHAR NOT NULL,           -- Which identity owns this lead
  linkedin_profile_url VARCHAR NOT NULL,
  linkedin_profile_id BIGINT,             -- Immutable ID (populate when known)
  full_name VARCHAR,
  company_name VARCHAR,
  location VARCHAR,                       -- For timezone/business hours
  
  -- Sequence state
  sequence_status VARCHAR DEFAULT 'NEW',  -- NEW, IN_SEQUENCE, PAUSED, REPLIED, ARCHIVED
  current_step INT DEFAULT 0,
  next_action_at TIMESTAMP,
  
  -- Connection state  
  connection_status VARCHAR DEFAULT 'NOT_CONNECTED',  -- NOT_CONNECTED, PENDING, CONNECTED
  connection_sent_at TIMESTAMP,
  
  -- Tracking
  last_contacted_at TIMESTAMP,
  replied_at TIMESTAMP,
  paused_reason VARCHAR,
  
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Conversations cache (sync from extract-conversations)
CREATE TABLE conversations (
  id UUID PRIMARY KEY,
  identity_id VARCHAR NOT NULL,
  linkedin_thread_id VARCHAR NOT NULL UNIQUE,
  participant_profile_id BIGINT,
  last_message_sender_id BIGINT,
  last_message_at TIMESTAMP,
  synced_at TIMESTAMP DEFAULT NOW()
);

-- Outreach log (audit trail)
CREATE TABLE outreach_log (
  id UUID PRIMARY KEY,
  lead_id UUID REFERENCES leads(id),
  action_type VARCHAR NOT NULL,           -- 'VISIT', 'CONNECT', 'MESSAGE', etc.
  step_number INT,
  status VARCHAR,                         -- 'SUCCESS', 'FAILED', 'SKIPPED'
  error_label VARCHAR,                    -- e.g., 'LIMIT_REACHED', 'NOT_CONNECTED'
  edges_run_id VARCHAR,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_leads_next_action ON leads(identity_id, sequence_status, next_action_at);
CREATE INDEX idx_conversations_participant ON conversations(identity_id, participant_profile_id);

Section 5: Lead Lifecycle State Machine

Visualize how leads progress through your sequence:

Part A: Main Outreach Flow

The happy path from new lead to reply:

Part B: Edge Cases (Already Connected / Existing Conversation)

Handle leads who are already in your network or have messaged first:

Part C: Identity Failure Recovery

When LinkedIn auth expires, pause affected leads and resume when fixed:

Edge Cases Summary

ScenarioDetectionAction
Lead already connectedextract-connections contains leadSkip connect, go to ReadyToMessage
Recent conversation existslast_message within 3 daysWait for cooldown before messaging
Lead messaged firstextract-conversations shows lead initiatedMark as REPLIED, human takes over
Identity auth expiredWebhook AUTH_EXPIRED eventPAUSE all leads for that identity

Section 6: Action Limits Quick Reference

Each action in the sequence has specific daily limits. Link to the action doc for full response schema.
StepActionLimit (Classic / Sales Nav)Action Doc
Visitlinkedin-visit-profile80 / 500 per dayView →
Connectlinkedin-connect-profile25 / 30 per dayView →
Messagelinkedin-message-profile50 / 250 per dayView →
InMaillinkedin-inmail-profileBased on subscriptionView →
Extract Conversationslinkedin-extract-conversationsFree with EngagementView →
Extract Connectionslinkedin-extract-connections30,000 per dayView →
Connection notes limit: LinkedIn Classic accounts can only send 5 connection requests WITH a personalized note per month. After that, notes are silently dropped. Consider: connect without note, then message after they accept.
Full limits reference: LinkedIn Smart Limits →

Section 7: Error Labels for Outreach Actions

Each action returns specific error labels. Handle these in your code.
Error LabelActions AffectedMeaningHow to Handle
LIMIT_REACHEDAllDaily Smart Limit hitSchedule retry for tomorrow, use postponed_until from response
NOT_CONNECTEDmessage-profileCan’t message non-connectionSend connection request first
ALREADY_CONNECTEDconnect-profileAlready 1st degree connectionSkip to message step
INVITATION_PENDINGconnect-profileConnection request already sentWait for accept or withdraw
PROFILE_NOT_ACCESSIBLEAllProfile deleted, blocked, or URL changedRemove from sequence or re-enrich
AUTH_EXPIREDAllIdentity LinkedIn session expiredPause sequence, trigger re-auth webhook
INVALID_INPUTAllBad parameters (wrong URL format, etc.)Fix input, check against action docs
STATUS_429AllLinkedIn raw rate limit (not guarded)Exponential backoff, wait and retry
How to handle in code:
async function executeWithErrorHandling(identityId, lead, actionFn) {
  try {
    const result = await actionFn();
    return { success: true, result };
  } catch (error) {
    const errorLabel = error.error_label || 'UNKNOWN';
    
    switch (errorLabel) {
      case 'LIMIT_REACHED':
        // Retry tomorrow - use postponed_until if available
        const retryAt = error.postponed_until || tomorrow();
        await updateLead(lead.id, { next_action_at: retryAt });
        break;
        
      case 'ALREADY_CONNECTED':
        // Skip connect step, go to message
        await updateLead(lead.id, { 
          connection_status: 'CONNECTED', 
          current_step: 3  // Skip to message step
        });
        break;
        
      case 'INVITATION_PENDING':
        // Already sent, just wait
        await updateLead(lead.id, { connection_status: 'PENDING' });
        break;
        
      case 'NOT_CONNECTED':
        // Can't message - need to connect first
        await updateLead(lead.id, { current_step: 1 });  // Go to connect step
        break;
        
      case 'PROFILE_NOT_ACCESSIBLE':
        // Remove from sequence
        await updateLead(lead.id, { 
          sequence_status: 'ARCHIVED', 
          paused_reason: 'PROFILE_INACCESSIBLE' 
        });
        break;
        
      case 'AUTH_EXPIRED':
        // Pause all leads for this identity
        await pauseIdentityLeads(identityId, 'AUTH_EXPIRED');
        break;
        
      default:
        // Log and investigate
        console.error('Unexpected error:', error);
        await logOutreachError(lead.id, error);
    }
    
    return { success: false, errorLabel };
  }
}

function tomorrow() {
  const date = new Date();
  date.setDate(date.getDate() + 1);
  date.setHours(9, 0, 0, 0);  // 9am tomorrow
  return date.toISOString();
}
Full error reference: Error Reference →

Section 8: Optimized Sync Flow (last_message optimization)

The last_message field in extract-conversations contains the sender’s linkedin_profile_id. This lets you detect replies without calling extract-messages.
// Sync conversations and detect replies efficiently
async function syncConversations(identityId) {
  const userProfileId = await getUserProfileId(identityId);
  
  const response = await fetch('https://api.edges.run/v1/actions/linkedin-extract-conversations/run/live', {
    method: 'POST',
    headers: {
      'X-API-Key': EDGES_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      identity_ids: [identityId]
    })
  });
  
  const data = await response.json();
  
  for (const conv of data) {
    const lastSenderId = conv.last_message?.linkedin_profile_id;
    
    // If last message sender != user, the lead replied!
    if (lastSenderId && lastSenderId !== userProfileId) {
      const leadProfileId = conv.participants[0]?.linkedin_profile_id;
      await markLeadAsReplied(identityId, leadProfileId);
      // NO NEED to call extract-messages for reply detection!
    }
    
    // Cache conversation for de-dup
    await upsertConversation({
      identity_id: identityId,
      linkedin_thread_id: conv.linkedin_thread_id,
      participant_profile_id: conv.participants[0]?.linkedin_profile_id,
      last_message_sender_id: lastSenderId,
      last_message_at: conv.last_message?.delivered_at
    });
  }
}
When you DO need extract-messages:
  • Building a Lemlist-like inbox UI - Display full conversation threads to users in your app
  • Full conversation history for CRM sync
  • Message analytics/sentiment analysis
  • Compliance/audit requirements

Section 9: Sending Outreach Actions (Complete Examples)

Send a Connection Request

async function sendConnectionRequest(identityId, lead, message = null) {
  const response = await fetch('https://api.edges.run/v1/actions/linkedin-connect-profile/run/live', {
    method: 'POST',
    headers: {
      'X-API-Key': EDGES_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      identity_ids: [identityId],
      input: {
        linkedin_profile_url: lead.linkedin_profile_url,
        message: message  // Optional, 300 char limit. Only 5/month with note!
      }
    })
  });
  
  if (!response.ok) {
    const error = await response.json();
    throw error;
  }
  
  const data = await response.json();
  
  // Update lead status
  await updateLead(lead.id, {
    connection_status: 'PENDING',
    connection_sent_at: new Date().toISOString(),
    last_contacted_at: new Date().toISOString()
  });
  
  // Log the action
  await logOutreach(lead.id, 'CONNECT', data.run_id);
  
  return data;
}

Send a Message

async function sendMessage(identityId, lead, messageText) {
  const response = await fetch('https://api.edges.run/v1/actions/linkedin-message-profile/run/live', {
    method: 'POST',
    headers: {
      'X-API-Key': EDGES_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      identity_ids: [identityId],
      input: {
        linkedin_profile_url: lead.linkedin_profile_url,
        message: messageText
      }
    })
  });
  
  if (!response.ok) {
    const error = await response.json();
    throw error;
  }
  
  const data = await response.json();
  
  // Update lead status
  await updateLead(lead.id, {
    sequence_status: 'WAITING_REPLY',
    last_contacted_at: new Date().toISOString()
  });
  
  // Log the action
  await logOutreach(lead.id, 'MESSAGE', data.run_id);
  
  return data;
}

Section 10: De-duplication Logic

Before sending any outreach, check for existing conversations and pending requests.
async function canSendOutreach(identityId, lead) {
  // Check 1: Already replied?
  if (lead.sequence_status === 'REPLIED') {
    return { canSend: false, reason: 'ALREADY_REPLIED' };
  }
  
  // Check 2: Pending connection request?
  if (lead.connection_status === 'PENDING') {
    const daysSinceSent = daysBetween(lead.connection_sent_at, new Date());
    if (daysSinceSent < 21) {
      return { canSend: false, reason: 'CONNECTION_PENDING' };
    }
  }
  
  // Check 3: Existing conversation? (from synced data)
  const existingConv = await db.conversations.findFirst({
    where: {
      identity_id: identityId,
      participant_profile_id: lead.linkedin_profile_id
    }
  });
  
  if (existingConv) {
    const userProfileId = await getUserProfileId(identityId);
    
    // Lead replied - last message is from them
    if (existingConv.last_message_sender_id !== userProfileId) {
      await updateLead(lead.id, { sequence_status: 'REPLIED' });
      return { canSend: false, reason: 'LEAD_REPLIED' };
    }
    
    // We already messaged, check cooldown
    const daysSinceMessage = daysBetween(existingConv.last_message_at, new Date());
    if (daysSinceMessage < 7) {
      return { canSend: false, reason: 'COOLDOWN_ACTIVE' };
    }
  }
  
  return { canSend: true };
}

function daysBetween(date1, date2) {
  const diffMs = new Date(date2) - new Date(date1);
  return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}

Section 11: Business Hours & Timezone Sending

Send messages during the lead’s business hours for better response rates.
function getSendTime(lead) {
  // Default business hours: 9am - 6pm in lead's timezone
  const targetHour = 9 + Math.floor(Math.random() * 9);  // Random hour 9-17
  const targetMinute = Math.floor(Math.random() * 60);
  
  // Get lead's timezone offset (you'd lookup from location)
  const leadTimezone = getTimezoneFromLocation(lead.location) || 'America/New_York';
  
  const now = new Date();
  const sendTime = new Date(now);
  
  // Set to target time in lead's timezone
  sendTime.setHours(targetHour, targetMinute, 0, 0);
  
  // If it's past business hours today, schedule for tomorrow
  const nowInLeadTz = new Date(now.toLocaleString('en-US', { timeZone: leadTimezone }));
  if (nowInLeadTz.getHours() >= 18) {
    sendTime.setDate(sendTime.getDate() + 1);
  }
  
  // Skip weekends
  while (sendTime.getDay() === 0 || sendTime.getDay() === 6) {
    sendTime.setDate(sendTime.getDate() + 1);
  }
  
  return sendTime;
}

// Helper: Map location to timezone (implement based on your data)
function getTimezoneFromLocation(location) {
  const timezoneMap = {
    'San Francisco': 'America/Los_Angeles',
    'New York': 'America/New_York',
    'London': 'Europe/London',
    'Paris': 'Europe/Paris',
    // Add more mappings
  };
  
  for (const [city, tz] of Object.entries(timezoneMap)) {
    if (location?.includes(city)) return tz;
  }
  return null;
}

Section 12: Callback Correlation with custom_data

When using async execution, pass custom_data to correlate callbacks with your leads.
// Send async action with custom_data
async function sendAsyncMessage(identityId, lead) {
  const response = await fetch('https://api.edges.run/v1/actions/linkedin-message-profile/run/async', {
    method: 'POST',
    headers: {
      'X-API-Key': EDGES_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      identity_ids: [identityId],
      parameters: {
        message: 'Hi! I wanted to connect...'
      },
      inputs: [{
        linkedin_profile_url: lead.linkedin_profile_url,
        custom_data: {
          lead_id: lead.id,
          step: 'initial_message',
          identity_id: identityId
        }
      }],
      callback: {
        url: 'https://your-app.com/webhooks/edges'
      }
    })
  });
  
  return response.json();
}

// Handle callback in your webhook endpoint
app.post('/webhooks/edges', async (req, res) => {
  const { run_id, status, output, custom_data } = req.body;
  
  // custom_data contains exactly what you sent
  const { lead_id, step, identity_id } = custom_data;
  
  if (status === 'SUCCESS') {
    await updateLead(lead_id, {
      sequence_status: 'WAITING_REPLY',
      last_contacted_at: new Date().toISOString()
    });
  } else {
    await logOutreachError(lead_id, output.error_label);
  }
  
  res.status(200).send('OK');
});
See Callbacks Documentation → for full callback payload structure.

Section 13: Rate Limits & Scaling

Understand both types of limits:

1. LinkedIn Smart Limits (per identity, per action)

These protect individual LinkedIn accounts from restrictions:

2. API Rate Limits (per workspace)

Based on your Edges plan tier. See API Rate Limits →

Choosing the Right Execution Mode

Choose based on when and how you need the action executed:
ModeUse CaseExample
liveReal-time user action, need immediate resultUser clicks “Send Message” in your UI
asyncBackground bulk operations, process callback laterImport 500 leads and message them all
scheduleRecurring automation on a scheduleDaily sync of connections at 9am
Implementation stays the same regardless of user count. Your architecture should support all modes from day 1 based on feature requirements, not scale.
Best practice: Space out requests. Instead of bursting 50 messages at once, spread them across the day using job queues or scheduled runs.

Section 14: When to Sync Data

Knowing when to sync connections and conversations is critical for accurate outreach.

Connections Extraction

WhenWhyMode
After identity connects LinkedInInitial cache of existing connectionslive or async
Daily (e.g., 9am)Catch newly accepted requestsschedule
Before starting a new leadCheck if already connectedPart of lead import flow
// Schedule daily connections sync (use /run/schedule)
// Or call this on a cron job using /run/async
async function syncConnections(identityId) {
  const response = await fetch('https://api.edges.run/v1/actions/linkedin-extract-connections/run/live', {
    method: 'POST',
    headers: {
      'X-API-Key': EDGES_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      identity_ids: [identityId]
    })
  });
  
  const connections = await response.json();
  
  // Update your connections cache
  for (const conn of connections) {
    await upsertConnection({
      identity_id: identityId,
      linkedin_profile_id: conn.linkedin_profile_id,
      linkedin_profile_url: conn.linkedin_profile_url,
      connected_at: conn.connected_at
    });
  }
  
  return connections.length;
}

Conversations Extraction

WhenWhyMode
Before sending any messageCheck for replies (sync-before-send)live
Every 1-4 hoursUpdate conversation cache, detect new repliesschedule or cron
On demand (inbox UI)Show user their latest conversationslive
Schedule mode is ideal for recurring syncs. Set up a schedule to run extract-connections daily and extract-conversations every few hours. See Scheduled Runs →

Section 15: Sync-Before-Send Pattern (Race Condition Prevention)

Problem: If you sync at 8:00am but send at 8:05am, lead could have replied at 8:03am. Solution: Always sync immediately before sending:
async function executeOutreachWithSync(identityId, lead) {
  // Step 1: Fresh sync for this specific lead
  // Note: extractConversations() wraps the API call from Section 8
  const conversations = await extractConversations(identityId, { max_results: 50 });
  
  // Step 2: Check if lead replied since last sync
  const userProfileId = await getUserProfileId(identityId);
  
  for (const conv of conversations) {
    const participantId = conv.participants[0]?.linkedin_profile_id;
    
    if (participantId === lead.linkedin_profile_id) {
      if (conv.last_message?.linkedin_profile_id !== userProfileId) {
        // Lead replied! Abort outreach
        await updateLead(lead.id, { sequence_status: 'REPLIED' });
        return { status: 'skipped', reason: 'LEAD_REPLIED_SINCE_SYNC' };
      }
    }
  }
  
  // Step 3: Safe to send
  return await sendMessage(identityId, lead);
}
Acceptable staleness: 5-10 minutes for most use cases. For high-value leads, sync immediately before.

Section 16: Identity Failure Recovery

Prerequisite: Set up integration webhooks. See Monitor LinkedIn Integration Status → When identity auth expires (webhook event: AUTH_EXPIRED):
// Webhook handler for identity status changes
app.post('/webhooks/edges/integration', async (req, res) => {
  const { identity_uid, event_type, integration_type } = req.body;
  
  if (integration_type !== 'linkedin') {
    return res.status(200).send('OK');
  }
  
  switch (event_type) {
    case 'AUTH_EXPIRED':
      await handleIdentityAuthExpired(identity_uid);
      break;
    case 'AUTH_SUCCESS':
      await handleIdentityAuthRestored(identity_uid);
      break;
  }
  
  res.status(200).send('OK');
});

async function handleIdentityAuthExpired(identityUid) {
  // 1. Pause all leads in active sequences for this identity
  await db.leads.updateMany({
    where: {
      identity_id: identityUid,
      sequence_status: { in: ['IN_SEQUENCE', 'WAITING_REPLY'] }
    },
    data: {
      sequence_status: 'PAUSED',
      paused_reason: 'IDENTITY_AUTH_EXPIRED',
      paused_at: new Date()
    }
  });
  
  // 2. Notify user to re-authenticate
  const user = await getUserFromIdentity(identityUid);
  await sendNotification(user.id, {
    type: 'LINKEDIN_AUTH_EXPIRED',
    message: 'Your LinkedIn connection expired. Please reconnect to resume sequences.',
    action_url: '/settings/integrations'
  });
}

async function handleIdentityAuthRestored(identityUid) {
  // Resume paused leads
  await db.leads.updateMany({
    where: {
      identity_id: identityUid,
      sequence_status: 'PAUSED',
      paused_reason: 'IDENTITY_AUTH_EXPIRED'
    },
    data: {
      sequence_status: 'IN_SEQUENCE',
      paused_reason: null,
      next_action_at: new Date()  // Reschedule immediately
    }
  });
}

Section 17: Handling Edge Cases (Already Connected, Lead Messaged First)

When adding a lead to a sequence, check for existing relationships:
async function addLeadToSequence(identityId, lead) {
  // Step 1: Check if already connected
  // Note: Helper functions wrap the API calls shown in previous sections
  const connections = await extractConnections(identityId, { max_results: 1000 });
  
  const isConnected = connections.some(
    conn => conn.linkedin_profile_id === lead.linkedin_profile_id
  );
  
  if (isConnected) {
    lead.connection_status = 'CONNECTED';
    lead.current_step = 3;  // Skip to message step
    await saveLead(lead);
    return;
  }
  
  // Step 2: Check if conversation exists (lead may have messaged first)
  const conversations = await extractConversations(identityId, { max_results: 100 });
  
  for (const conv of conversations) {
    if (conv.participants[0]?.linkedin_profile_id === lead.linkedin_profile_id) {
      // Existing conversation! Check who initiated
      const userProfileId = await getUserProfileId(identityId);
      
      if (conv.last_message?.linkedin_profile_id === lead.linkedin_profile_id) {
        lead.sequence_status = 'REPLIED';
        lead.replied_at = conv.last_message.delivered_at;
        await saveLead(lead);
        return;  // Don't start sequence, human should handle
      }
    }
  }
  
  // Step 3: Normal start
  lead.sequence_status = 'IN_SEQUENCE';
  lead.current_step = 0;
  lead.next_action_at = new Date();
  await saveLead(lead);
}

Section 18: Complete Orchestrator Example

This ties everything together. A daily cron job that processes all leads for all identities.
// Main orchestrator - runs daily via cron
async function runDailyOutreachForAllIdentities() {
  // Get all active identities
  const identities = await db.identities.findMany({
    where: { status: 'ACTIVE', linkedin_connected: true }
  });

  for (const identity of identities) {
    try {
      await runOutreachForIdentity(identity.id);
    } catch (error) {
      console.error(`Failed for identity ${identity.id}:`, error);
      // Continue with other identities
    }
  }
}

async function runOutreachForIdentity(identityId) {
  // Step 1: Sync conversations to detect replies (Section 8)
  await syncConversations(identityId);

  // Step 2: Get leads due for action today
  const leads = await db.leads.findMany({
    where: {
      identity_id: identityId,
      sequence_status: 'IN_SEQUENCE',
      next_action_at: { lte: new Date() }
    }
  });

  for (const lead of leads) {
    // Step 3: De-dup check (Section 10)
    const { canSend, reason } = await canSendOutreach(identityId, lead);
    if (!canSend) {
      console.log(`Skipping lead ${lead.id}: ${reason}`);
      continue;
    }

    // Step 4: Sync-before-send for race condition prevention (Section 14)
    const replyCheck = await checkForRecentReply(identityId, lead);
    if (replyCheck.hasReplied) {
      await updateLead(lead.id, { sequence_status: 'REPLIED' });
      continue;
    }

    // Step 5: Calculate send time for business hours (Section 11)
    const sendTime = getSendTime(lead);
    if (sendTime > new Date()) {
      await updateLead(lead.id, { next_action_at: sendTime });
      continue;
    }

    // Step 6: Execute the action (Section 9) with error handling (Section 7)
    await executeWithErrorHandling(identityId, lead, async () => {
      return await executeSequenceStep(identityId, lead);
    });
  }
}

async function executeSequenceStep(identityId, lead) {
  const SEQUENCE_STEPS = [
    { step: 0, action: 'visit', delayDays: 1 },
    { step: 1, action: 'connect', delayDays: 3 },
    { step: 2, action: 'follow_up_connect', delayDays: 4 },  // If not connected after 3 days
    { step: 3, action: 'message', delayDays: 7 },  // After connection accepted
    { step: 4, action: 'follow_up_1', delayDays: 7 },
    { step: 5, action: 'follow_up_2', delayDays: 7 },
    { step: 6, action: 'archive', delayDays: 0 }
  ];

  const currentStep = SEQUENCE_STEPS[lead.current_step];
  
  // Check connection status for message steps
  if (['message', 'follow_up_1', 'follow_up_2'].includes(currentStep.action)) {
    if (lead.connection_status !== 'CONNECTED') {
      // Not connected yet - check if connection was accepted
      // Note: Helper functions wrap API calls (see Section 9)
      const connections = await extractConnections(identityId, { max_results: 100 });
      const isNowConnected = connections.some(
        c => c.linkedin_profile_id === lead.linkedin_profile_id
      );
      
      if (isNowConnected) {
        await updateLead(lead.id, { connection_status: 'CONNECTED' });
      } else {
        // Still not connected - wait or move to follow-up connect
        return;
      }
    }
  }
  
  switch (currentStep.action) {
    case 'visit':
      await visitProfile(identityId, lead);
      break;

    case 'connect':
      await sendConnectionRequest(identityId, lead);
      break;

    case 'message':
    case 'follow_up_1':
    case 'follow_up_2':
      const template = MESSAGE_TEMPLATES[currentStep.action];
      const message = personalizeMessage(template, lead);
      await sendMessage(identityId, lead, message);
      break;

    case 'archive':
      await updateLead(lead.id, { sequence_status: 'ARCHIVED' });
      return;
  }

  // Advance to next step
  await updateLead(lead.id, {
    current_step: lead.current_step + 1,
    next_action_at: addDays(new Date(), currentStep.delayDays),
    last_contacted_at: new Date()
  });
}

// Message templates
const MESSAGE_TEMPLATES = {
  message: "Hi {{first_name}}, I noticed you're working at {{company_name}}. I'd love to connect and share some ideas about...",
  follow_up_1: "Hi {{first_name}}, just following up on my previous message. Would love to hear your thoughts on...",
  follow_up_2: "{{first_name}}, last follow-up from me! If now isn't a good time, no worries. Feel free to reach out whenever..."
};

function personalizeMessage(template, lead) {
  return template
    .replace('{{first_name}}', lead.full_name?.split(' ')[0] || 'there')
    .replace('{{company_name}}', lead.company_name || 'your company');
}

function addDays(date, days) {
  const result = new Date(date);
  result.setDate(result.getDate() + days);
  return result;
}
How to run:
  • Set up a cron job: 0 8 * * * python run_daily_outreach.py
  • Or use a job queue (Bull, Celery, etc.) for better control

Section 19: Testing Strategy

Challenge: Can’t spam real LinkedIn users during development.

Approach 1: Test Accounts

  • Create 2-3 LinkedIn test accounts (personal accounts you control)
  • Use these as “leads” for end-to-end testing
  • Verify messages arrive, connections work

Approach 2: Dry Run Mode

async function executeAction(identityId, lead, dryRun = false) {
  if (dryRun) {
    // Log what WOULD happen without calling API
    console.log(`DRY RUN: Would send ${lead.current_step} to ${lead.full_name}`);
    return { status: 'dry_run', action: lead.current_step };
  }
  
  // Real execution
  return await executeSequenceStep(identityId, lead);
}

Approach 3: Staging Environment

  • Use a separate Edges workspace for testing
  • Connect test identities only
  • Isolates production data

Approach 4: Unit Test Mocks

// Jest example - mock Edges responses
describe('Outreach Sequence', () => {
  beforeEach(() => {
    jest.spyOn(global, 'fetch').mockImplementation((url) => {
      if (url.includes('extract-conversations')) {
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve({
            output: {
              results: [{
                linkedin_thread_id: '2-test-thread',
                last_message: {
                  linkedin_profile_id: 123456789,  // Simulated lead reply
                  delivered_at: '2024-01-20T10:00:00Z'
                },
                participants: [{ linkedin_profile_id: 123456789 }]
              }]
            }
          })
        });
      }
      // Add more mock responses...
    });
  });

  test('detects lead reply correctly', async () => {
    const result = await syncConversations('identity_123');
    // Assert lead was marked as replied
  });
});

Section 20: API Reference & SDK

TypeScript SDK

For a cleaner developer experience, use the official TypeScript SDK:
npm install @edgesrun/sdk
See SDK Documentation →

Key Actions Reference

Don’t duplicate schemas - refer to live action docs for full request/response examples:
ActionKey Fields You NeedLive Docs
linkedin-melinkedin_profile_id (user’s own ID)View →
linkedin-extract-conversationslast_message.linkedin_profile_id, linkedin_thread_idView →
linkedin-extract-connectionslinkedin_profile_id, connected_atView →
linkedin-message-profilelinkedin_thread_id (returned on success)View →
linkedin-connect-profileSuccess/failure statusView →
linkedin-visit-profileSuccess/failure statusView →
linkedin-search-peoplelinkedin_profile_url, full_name, headlineView →

Summary

You now have everything needed to build a production-ready LinkedIn outreach sequence:
FeatureSection
Identity setup & concepts1
Lead sourcing2
User profile ID for reply detection3
Database schema4
State machine for lead lifecycle5
Action limits6
Error handling per action7
Optimized sync with last_message8
Sending messages & connections9
De-duplication logic10
Business hours sending11
Callback correlation12
Rate limits & scaling13
Sync-before-send pattern14
Identity failure recovery15
Edge case handling16
Complete orchestrator17
Testing strategies18
API reference & SDK19
Questions? Check the FAQ or reach out to support.