Personalizing fifty LinkedIn connection requests by hand eats a whole morning and still looks like you rushed the last ten. n8n LinkedIn outreach automation fixes the scale problem: pull a prospect list from a sheet, have an AI node draft a short note from each person's real profile, send the invite, and log who actually connects, no tab-switching required. The part that trips people up isn't the workflow, it's that n8n has no outreach node to drag onto the canvas for this, because LinkedIn's own API doesn't allow it.
That's not a dead end, it's just a different foundation. Unipile is a paid API that already did the work of talking to LinkedIn the way its own apps do, and n8n's generic HTTP Request node calls it like any other API. This walkthrough builds the invite-and-track loop on that foundation, with the actual hard part, pacing it so your account doesn't get flagged, built in from the start rather than bolted on after a scare.
Before you start
You'll need an n8n instance (a free n8n Cloud trial works fine here), a paid Unipile account with your LinkedIn account connected through it, a Google Sheet to hold your prospect list, and a Chat Model credential (OpenAI, Anthropic, whichever you already use elsewhere in n8n) for the message-writing step.
Disclosure: the n8n Cloud link above is an affiliate link — if you sign up through it, we may earn a commission at no extra cost to you. See our affiliate disclosure for details.
Worth saying plainly before you build anything: LinkedIn's User Agreement prohibits automated use of the platform, and Unipile works by replicating what LinkedIn's own apps do rather than through a blessed partner API. Paced sensibly it looks like a genuinely active user; pushed hard it's how accounts get restricted. Treat the pacing numbers later in this post as load-bearing, not optional reading.
Your sheet needs a column for each of: Name, ProfileUrl, ProviderId, Headline, Status, Note, InvitedDate, ConnectedDate. Every new prospect starts with Status set to Pending and everything else blank, since the workflow fills in the rest.
Step 1: Connect your LinkedIn account to Unipile
Unipile's Hosted Auth Wizard is the fastest way to link an account: one API call generates a short-lived URL, you open it and log into LinkedIn as you normally would, and Unipile hands back an account_id tied to that login on a callback URL you configure (notify_url, alongside type: "create" and providers: ["LINKEDIN"] in the request body, per Unipile's own docs). Since this workflow only ever drives one LinkedIn account, the simplest place to keep that account_id is as a literal value pasted directly into the two HTTP Request nodes below, rather than threading it through every item, because every request from here on needs it.
I don't have a live Unipile account in this sandbox to actually run this step against, so this part is docs-cross-checked against Unipile's hosted-auth reference, not executed.
Step 2: Pull today's batch from the sheet
Add a Schedule Trigger (once a day, e.g. 9am) feeding into a Google Sheets node set to Get Row(s), filtered to Status = Pending. Follow it with a Code node that caps how many you'll actually process today, which is the first line of defense against blowing past LinkedIn's daily ceiling, covered in full below:
const DAILY_CAP = 20; // start low; see the pacing section before raising this
const rows = $input.all().map(i => i.json);
const batch = rows
.filter(r => r.Status === "Pending")
.slice(0, DAILY_CAP);
return batch.map(row => ({ json: row }));
I ran this filter-and-cap logic standalone against a mixed list of Pending/Invited/Connected rows: it returns only the pending ones, respects the cap when there are more pending rows than the cap allows, and returns everything pending when there are fewer. The version above is a direct port of that logic into n8n's Code node shape, not a rewrite.
Step 3: Look up each profile through Unipile
For each row, add an HTTP Request node: GET https://{your-subdomain}.unipile.com:{port}/api/v1/users/{identifier}?account_id={your_account_id}, with an X-API-KEY header carrying your Unipile access token. {identifier} is the slug from the end of the person's profile URL, not their name, so pull it out first in a small Code node:
function extractIdentifier(profileUrl) {
const match = profileUrl.trim().match(/linkedin\.com\/in\/([^/?#]+)/i);
if (!match) return null;
return match[1];
}
const url = $json.ProfileUrl;
const identifier = extractIdentifier(url);
if (!identifier) {
throw new Error(`Couldn't parse a LinkedIn slug out of: ${url}`);
}
return [{ json: { ...$json, identifier } }];
I verified extractIdentifier standalone against a profile URL with a trailing slash, one without, one carrying a tracking query string, and a deliberately malformed non-LinkedIn URL. It pulls the clean slug in the first three cases and returns null (rather than throwing) on the last, which the wrapper above turns into a clear error instead of a silent bad request.
The response carries the person's provider_id (needed for the invite in Step 5) and their headline, so write both back into the row with a Google Sheets Update Row node, matching on ProfileUrl, before moving on.
Blip: LinkedIn's official API won't tell you someone's headline without a partnership agreement most of us will never qualify for. Unipile gets it by pretending to be the app on your phone. Welcome to the actual API landscape.
Step 4: Draft the personalized note
Add a Basic LLM Chain node connected to your Chat Model, with a prompt built from the headline you just pulled, something like: Write a one-sentence, specific LinkedIn connection note (no greeting, no sign-off) referencing this person's role: {{ $json.Headline }}. Keep it under 250 characters. Asking the model to stay under budget helps, but it isn't reliable enough to skip the hard limit below.
LinkedIn caps a connection note at 300 characters, counting spaces, so the next Code node enforces that regardless of what the model actually returned:
function fitNote(note, maxLen = 300) {
const trimmed = note.trim();
if (trimmed.length <= maxLen) return trimmed;
const cut = trimmed.slice(0, maxLen);
const lastSpace = cut.lastIndexOf(" ");
const safe = lastSpace > maxLen * 0.6 ? cut.slice(0, lastSpace) : cut;
return safe.replace(/[.,;:\-\s]+$/, "").trim();
}
return [{ json: { ...$json, Note: fitNote($json.Note) } }];
I ran this against a short note (left untouched), and a deliberately long one built to clear 300 characters. The trimmed result stayed under the limit, matched a clean prefix of the original text, and never cut mid-word or left a dangling comma or dash at the end. Same logic, same port into the Code node, as the two steps above.
Step 5: Send the invite
Another HTTP Request node, POST https://{your-subdomain}.unipile.com:{port}/api/v1/users/invite, X-API-KEY header as before, JSON body built like this:
const YOUR_ACCOUNT_ID = "{your_account_id}"; // from Step 1's hosted auth callback
function buildInviteBody(accountId, providerId, note) {
const body = { account_id: accountId, provider_id: providerId };
if (note) body.message = note;
return body;
}
return [{ json: buildInviteBody(YOUR_ACCOUNT_ID, $json.ProviderId, $json.Note) }];
Verified standalone too: an empty note correctly omits the message field entirely (Unipile treats a present-but-empty field differently from an absent one, per its docs), and a real note comes through intact in the body.
On the HTTP Request node itself, turn on Retry On Fail with a couple of retries for transient 500s, but set On Error to Continue (using error output) rather than stopping the whole batch, since a single bad provider_id shouldn't block the other nineteen invites in today's run. LinkedIn (via Unipile) returns 422 cannot_resend_yet if you've already invited that person recently, which you want to catch and just mark Skipped rather than treat as a real failure.
Step 6: Record the invite
Google Sheets Update Row, matching on ProfileUrl again: set Status to Invited and InvitedDate to today. This is what Step 2's filter checks next run, so skipping it means re-inviting the same people tomorrow, and re-invites are exactly what trips the cannot_resend_yet error above.
Step 7: Catch acceptances with a second workflow
Sending the invite is the easy half; knowing who said yes is the part a naive version skips entirely. Unipile fires a new_relation webhook event when a pending invitation turns into an actual connection, so create a "USERS WEBHOOK" subscription for that event pointed at a new n8n Webhook node's URL, then follow it with a Code node that matches the payload back to your sheet:
function matchAcceptedInvite(rows, webhookPayload) {
const providerId = webhookPayload.user_provider_id;
if (!providerId) return null;
const row = rows.find(r => r.ProviderId === providerId && r.Status === "Invited");
if (!row) return null;
return {
rowNumber: row.rowNumber,
update: { Status: "Connected", ConnectedDate: new Date().toISOString().slice(0, 10) },
};
}
I tested this against a matching Invited row (returns the right update), a user_provider_id that isn't in the sheet at all (someone you never invited through this workflow accepting a connection some other way), which correctly returns nothing instead of erroring, a row already marked Connected (so a duplicate webhook delivery doesn't double-process it), and a malformed payload missing user_provider_id entirely, which also returns nothing rather than throwing. Feed the sheet rows in via a Google Sheets Get Row(s) node ahead of this Code node, and when it returns a match, follow with Update Row to actually write the status change.
Blip: The new_relation event can show up up to 8 hours after someone actually accepts. Unipile polls LinkedIn's relation list at random intervals instead of real time, specifically so the checking itself doesn't look automated. An automation that pretends not to be one, to avoid detection as one. It's layers all the way down.
Pace your LinkedIn outreach automation like a human
Unipile enforces no sending limit of its own, so nothing stops you from blowing past a safe pace except the cap you set yourself. Whatever ceiling you hit is LinkedIn's, reached through Unipile's API instead of the LinkedIn UI. For an established, paid LinkedIn account, a sustainable pace is roughly 80-100 invitations a day and around 200 a week. For a new or quiet profile, start at 10-20 a day and ramp up over two to three weeks rather than jumping straight to that ceiling, which is exactly what the DAILY_CAP constant in Step 2 is there to enforce. Go over it and you'll see 422 cannot_resend_yet on repeat targets and 429/500 responses on fresh ones; neither is a bug to route around, both are LinkedIn telling you to slow down.
What this doesn't solve
This covers the connection-request half of outreach, not what happens after someone accepts. That's a real conversation, not another automated message, and this workflow deliberately stops at Connected. It also doesn't cover InMail or Sales Navigator's advanced search, which sit behind different parts of Unipile's API than what's used here. And none of the pacing advice above is a guarantee: it reflects what's worked as of this writing, not a promise that LinkedIn's detection won't tighten later. If you're tempted to scale past the weekly ceiling by rotating multiple LinkedIn accounts through the same workflow, don't. That pattern is closer to what detection systems are actually built to catch than the pacing itself.
Where to start
Build Steps 1 through 3 first and let them run against a handful of rows without ever hitting Step 5, confirming the profile lookups and sheet writes are landing correctly before anything gets sent. Then trigger Steps 4 and 5 manually against one or two contacts you're fine reaching out to personally, and read the actual note it generated before trusting the model's judgment at scale. Only turn on the daily schedule once that's solid, and start it at the low end of the pacing range above, not the high end.
Top comments (0)