Direct actions
Send connection requests, messages, InMails, emails and other one-off actions from a sender account or a mailbox, outside campaigns, with idempotent retries and an approvals queue.
Direct actions drive a connected sender account one action at a time: connection requests, messages, InMails, profile visits, follows, post likes, post comments and connection lookups, all outside a campaign. A connected mailbox sends emails the same way, and a sender with Sales Navigator can search companies and read its saved lists. They are the building blocks for custom outreach tooling: your own scheduler, a CRM-triggered follow-up, a one-off send from an internal dashboard. If you want Swarmhit to run a multi-step sequence for you, use Campaigns instead.
Every LinkedIn action runs from one sender account, so the endpoints live under /accounts/{id}/...; emails run from a mailbox, under /email-accounts/{id}/emails. Get the account id from List accounts. A paused sender rejects every POST action with 409 code: sender_paused; resume it first (see Sender accounts). Withdrawals and the reads still work while a sender is paused.
Each POST action counts against the sender's daily cap for that action type (the same counters its campaigns use, reset at UTC midnight), and connection requests also count against a rolling weekly limit and a cap on pending invitations. Read LinkedIn safety before building on these endpoints.
Endpoints
| Action | Method and path | Reference |
|---|---|---|
| Send a connection request | POST /accounts/{id}/connections | sendConnectionRequest |
| Withdraw a pending request | DELETE /accounts/{id}/connections/{identifier} | withdrawConnectionRequest |
| Message a connection | POST /accounts/{id}/messages | sendDirectMessage |
| Send an InMail | POST /accounts/{id}/inmails | sendInmail |
| Visit a profile | POST /accounts/{id}/profile-views | visitProfile |
| Follow a lead | POST /accounts/{id}/follows | followProfile |
| Like a post, or react to it | POST /accounts/{id}/post-likes | likeLastPost |
| Comment on a post, or reply to a comment | POST /accounts/{id}/post-comments | commentLastPost |
| List a post's comments | GET /accounts/{id}/post-comments | listPostComments |
| Check connection status | GET /accounts/{id}/connections/{identifier} | checkConnection |
| Look up a profile | GET /accounts/{id}/profiles/{identifier} | lookupProfile |
| Search Sales Navigator companies | GET /accounts/{id}/sales-navigator/account-search | salesNavigatorAccountSearch |
| List saved Sales Navigator lists | GET /accounts/{id}/sales-navigator/lists | listSalesNavigatorLists |
| List direct actions | GET /accounts/{id}/actions | listOutreachActions |
| Send an email | POST /email-accounts/{id}/emails | sendEmail |
| List emails sent through the API | GET /email-accounts/{id}/emails | listDirectEmails |
The connection-status and profile-lookup reads are silent: no profile view is registered on LinkedIn and no cap is used. Listing a post's comments is a silent read too, and so are the two Sales Navigator reads. Withdrawing a request uses no cap either.
How targets are resolved
POST endpoints take the target as a profileUrl in the JSON body. Swarmhit resolves the URL to a live LinkedIn profile before acting; if it matches no profile, the request fails with 404 and code: profile_not_found (distinct from a plain 404, which means the Swarmhit resource in the path does not exist).
The GET reads and the withdrawal take the profile's public identifier in the path instead: the username in linkedin.com/in/{identifier}, for example john-doe.
Use the username form in both places, in lowercase. An identifier with uppercase letters or underscores is read as LinkedIn's internal member id (as in linkedin.com/in/ACoAA...), which these endpoints do not accept: POST actions and the GET reads fail with 400, and a withdrawal finds nothing to cancel (alreadyGone: true).
On sendDirectMessage and sendInmail you can also pass providerId, the recipient's stable LinkedIn id: lead.providerId on a connection.accepted webhook, or from.providerId on message.received. When supplied, the send skips the profile lookup, which is the slow round-trip on this path. profileUrl is still required, in the username form, so the contact can be tracked. Store the id when the webhook arrives and reuse it for follow-up sends.
Sending an invite
curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/connections" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2a4e-9d3b-4c1a-8e7f-2b6d9a1c3e5f" \
-d '{
"profileUrl": "https://www.linkedin.com/in/john-doe",
"note": "Hi John, enjoyed your post on onboarding flows. Would love to connect."
}'The response data is the logged action. A connection request starts with status: pending and later becomes accepted or withdrawn; every other action is logged as sent. The profile is tracked as a lead in your workspace, and a connection.accepted webhook (source: direct) fires when the invitation is accepted.
Notes are a premium feature: up to 300 characters (a longer note is rejected with 400 and nothing is sent). Free sender accounts cannot include one, so their invites go out without the note rather than failing, and the returned action has note: null. If this sender already has a pending invitation to the profile from an earlier direct request, the send returns 409. To cancel a pending request, call withdrawConnectionRequest; it is safe to call when nothing is pending and returns { withdrawn, alreadyGone }, with alreadyGone: true when LinkedIn has no pending invitation left to cancel. It also marks the logged invitation withdrawn, which clears the pending status Swarmhit reports for that profile.
Messages and InMails
sendDirectMessage messages a profile the sender is already connected to. It takes profileUrl and text, plus the optional providerId. To reply inside an existing conversation, use the Inbox endpoint (sendChatMessage) instead.
curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/messages" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8a1d3f5b-2c4e-4a6d-9b8c-7e5f1a3d2c4b" \
-d '{
"profileUrl": "https://www.linkedin.com/in/john-doe",
"providerId": "PROVIDER_ID_FROM_WEBHOOK",
"text": "Thanks for connecting, John!"
}'sendInmail reaches non-connections. It takes profileUrl, text and an optional subject, plus the optional providerId. It requires a premium LinkedIn plan on the sender (free senders cannot send any; you get 409 code: inmail_not_available). Sales Navigator and Recruiter senders InMail open profiles free up to a monthly allowance (about 800 per month, plan-dependent); every other send, including classic Premium, draws the sender's InMail credit balance, visible as inmailCredits on the account. When credits run out you get 429 code: inmail_credits_exhausted.
Not sure which channel applies? checkConnection returns the sender's relationship with a profile: status (not_connected, pending, connected), connection degree, openProfile, and whether this sender canMessage or canInvite them, without using any cap. pending only reflects invitations this sender sent through direct actions: an invitation sent by a campaign or from LinkedIn itself shows as not_connected. canMessage is true for a connection, or for an open profile when the sender has Sales Navigator or Recruiter. lookupProfile returns the profile's name, headline and picture with the same relationship fields under relationship.
Visits, follows, likes and comments
visitProfile registers a real profile view (the person sees it), and followProfile follows the lead without connecting. Both take profileUrl.
likeLastPost and commentLastPost act on a post. Pass one of:
profileUrl: the lead's most recent post, whatever its age. They fail with422when the lead has no post.postUrl: that exact post, by the link LinkedIn gives it:linkedin.com/posts/...("Copy link to post") orlinkedin.com/feed/update/urn:li:activity:...(alsourn:li:ugcPost:andurn:li:share:). A link that isn't a post's, or a post the account can't see, fails with422code: post_not_found.
likeLastPost leaves a like unless you pass a reactionType: celebrate, support, love, insightful or funny. Any reaction counts as one post like. commentLastPost takes the exact comment text. With a postUrl, it can reply to one of the post's comments instead: pass that comment's commentId, or use the comment's own link (LinkedIn's "Copy link to comment") as the postUrl. A reply that can't be made, because the comment in the link can't be read or the comment is on another post, fails with 400 and nothing is posted: it never becomes a comment on the post.
curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/post-comments" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d2b8e4a-7c1f-4b3e-9a6d-2f8c4e1b7a5d" \
-d '{
"postUrl": "https://www.linkedin.com/posts/jane-doe_hiring-activity-7332661864792854528-hcGT",
"commentId": "7335000001439513601",
"text": "Thanks for sharing this, Sam."
}'listPostComments returns a post's comments, most recent first, so you can pick one to reply to. Each has its id (the commentId a reply takes), text, author (name, headline, profileUrl, picture), createdAt, replyCount and reactionCount. Replies to a comment aren't listed. Paginate by passing paging.nextCursor back as cursor.
On likes and comments, the action's postUrl is the post acted on, a comment's text is in note, replyTo is the comment a reply answered, and reactionType is the reaction left. A post given by its link isn't tied to a lead: no lead is created and leadId is null, and profileUrl is the post's author, or null when a company wrote it.
By default a sender can make 40 profile views, 30 follows, 30 post likes and 15 post comments per day.
Sales Navigator company search and saved lists
A sender with a Sales Navigator seat can search Sales Navigator for companies and read its saved lists. Both are reads on the sender's own session: nothing is sent, no cap is used, and they aren't logged as direct actions.
salesNavigatorAccountSearch runs an account (company) search and returns the companies directly, 25 a page. Pass one of:
keywords: free text, as typed in Sales Navigator's search box.url: the link of a Sales Navigator company (account) search, copied from the browser's address bar (https://www.linkedin.com/sales/search/company?query=...), to run it with its filters as they are.
With both, the url is used. The first search on a sender can take a few minutes, while Swarmhit signs it in to Sales Navigator, so allow up to 4 minutes before timing out.
The url must be a company search with its search in query, 4,000 characters at most. Any other link fails with 400 and code: invalid_search_url, even with keywords, and nothing is sent to LinkedIn: a Sales Navigator people search, a classic LinkedIn search, a Sales Navigator page with no search in it, or a link that isn't LinkedIn's. The message says what the link is. A link pasted without https://, or with http://, is read as the https:// page. To turn a people search into leads, use a lead import.
curl "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/sales-navigator/account-search?keywords=logistics%20software" \
-H "Authorization: Bearer swh_live_..."Each company has its Sales Navigator id, name, industry, location, employeeCount, saved (whether it is already in one of the sender's saved lists), profileUrl (its Sales Navigator page) and picture (a logo link that expires). A field LinkedIn doesn't give is null. paging.total is the number of matching companies when LinkedIn reports it, otherwise null. For the next page, pass paging.nextCursor back as cursor, with the same keywords or url. It is null when there are no more pages; when paging.total is null, the last page can be empty.
listSalesNavigatorLists returns the sender's saved lists: lead lists by default, or account lists with type=ACCOUNT. Each has its id, name, type (LEAD or ACCOUNT), totalCount (how many leads or accounts it holds), description, shared, createdAt and updatedAt. It returns up to 100 lists, with no paging.
These two reads fail differently from the actions:
| Status | Meaning |
|---|---|
400 | Neither keywords nor url, a url that isn't a Sales Navigator company search (code: invalid_search_url), or a type other than LEAD or ACCOUNT. |
409 | The account isn't connected or can't be used right now, for example because LinkedIn refused its session and it may need reconnecting. The message says why. |
422 | Not available for this account: it has no Sales Navigator seat, Sales Navigator search isn't available on it yet, or the search can't run for it. Retrying won't help. |
429 | LinkedIn is rate-limiting the account. Back off and retry later. |
502 | LinkedIn is temporarily unavailable. Retry later. |
503 | Sales Navigator isn't reachable for this account right now, for example while its sign-in is still running. Retry in a minute. |
Emails
sendEmail sends one email from a connected mailbox to an address, outside any campaign. It takes to, subject and a plain-text text; the mailbox's signature is added, and its open and click tracking settings apply.
curl -X POST "https://app.swarmhit.com/api/v1/email-accounts/MAILBOX_ID/emails" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3c9e1f7a-5b2d-4e8f-a1c6-9d0b7e4f2a8c" \
-d '{
"to": "maya@lindqvist.io",
"subject": "Quick question about your Q4 hiring",
"text": "Hi Maya,\n\nSaw your post on scaling the support team. Worth a quick chat?"
}'The response data is the sent email: its status, to, subject, RFC 5322 messageId, the leadId the address belongs to (a lead that already has the address is reused, otherwise one is created), and chatId, the inbox chat holding the thread. To follow up in the same thread, reply in that chat with sendChatMessage; another sendEmail starts a new thread.
Like a campaign email, it counts against the mailbox's daily email cap (limits.emails, reset at UTC midnight) and the workspace's email allowance, and it fires email.sent with source: direct. A reply lands in the same thread and fires email.received with source: direct. A bounce or spam report fires email.bounced and turns the email's status to bounced, with the reason in error; an email that could not be delivered at all turns failed. listDirectEmails lists a mailbox's emails, newest first, filterable by status.
Nothing is sent, and the request fails with 409, when:
| Code | Meaning |
|---|---|
mailbox_paused | The mailbox is paused. Resume it first. |
mailbox_warming | Warmup still holds the mailbox (see The hold). |
mailbox_not_connected | The mailbox is not connected. Reconnect it. |
email_opted_out | The address unsubscribed or reported an email as spam. |
email_bounced | Email to the address bounced for good before. |
The email channel has to be enabled for the workspace (403 otherwise), and while email is paused for the workspace the request fails with 400 and code: email-limit-reached.
Idempotent sends
Every POST direct action accepts an Idempotency-Key header: a unique key per action (a UUID works), up to 255 characters. Reusing the key replays the first response instead of sending again, so a retry cannot double-send. It is recommended on all writes.
The failure mode it exists for: your request times out at the network level, but the send actually went through. Without a key, retrying would message the person twice. With a key, retry the exact same request with the same key and you get the original response back.
A key is scoped to the endpoint and the sender account or mailbox, and is kept for 24 hours; after that, the same key runs the action again. Only a successful response is stored and replayed. A 4xx means the action did not happen, so it frees the key: once the cause is fixed (for example after a 429 window resets), retrying with the same key runs the action. A 5xx keeps the key, because the action may have gone out. The request body is not compared: a reused key replays the first response even if the new body differs.
Two idempotency-specific 409 codes can come back on a retry:
| Code | Meaning | What to do |
|---|---|---|
idempotency_in_progress | A request with the same key is still in flight | Retry shortly |
idempotency_failed | The original's outcome couldn't be confirmed | Check whether it went through, then retry with a new key |
Error handling
Branch on error.code, not on error.message (the wording may change, codes will not).
| Status | Meaning |
|---|---|
400 | Invalid input, for example a missing profileUrl or text, both a postUrl and a profileUrl, a reactionType LinkedIn doesn't offer, a note over 300 characters, a profile URL or identifier that is not a username, or an Idempotency-Key over 255 characters |
404 code: profile_not_found | The profile URL or identifier matched no LinkedIn profile |
409 | A conflict: this sender already has a pending direct invitation to the profile, or the sender's LinkedIn session was rejected or has expired and it may need reconnecting. Codes: sender_paused, inmail_not_available, idempotency_in_progress, idempotency_failed, and for emails the codes above |
422 | LinkedIn could not complete the action on this profile: cannot invite, already connected, the recipient cannot be messaged by this sender, or (for post likes and comments) the lead has no recent post. For a post given by its link: code: post_not_found when the link isn't a post's or the account can't see the post; otherwise the post takes no comments or reactions from this account, or LinkedIn refused the comment, reply or reaction. For an email, the mailbox could not send it |
429 | A sender limit was hit, or LinkedIn is rate-limiting the sender. Codes: inmail_credits_exhausted for InMail credits, otherwise none |
502 | LinkedIn, or for an email the mail service, is temporarily unavailable. Retry later; with an Idempotency-Key, use a new key |
A 422 is about the target, not the request, so retrying it unchanged will usually fail again; skip the target or switch action type instead.
A 429 from a Swarmhit limit carries a details object { limit, used, scope, resetAt }:
scope: day: the daily cap for the action type.resetAtis the next UTC midnight.scope: week: connection requests the sender sent, from campaigns and direct actions, in a rolling 7-day window (tier-based: about 130 for free sender accounts, 150 for paid).resetAtis when the oldest request in the window ages out.scope: pending: the sender's pending-invitation backlog, campaigns included (500 by default). There is noresetAt; it clears as invitations are accepted or withdrawn.
An InMail credit refusal (code: inmail_credits_exhausted) has details.scope: inmail_credits instead, without limit, used or resetAt. A 429 with no details means LinkedIn itself is rate-limiting the sender: back off well beyond your usual spacing. Schedule your retry for resetAt rather than hammering the endpoint.
Tracking what was sent
listOutreachActions returns the sender's direct actions newest first, paged with limit (default 50, max 100) and offset, and filterable by type (invitation, message, inmail, profile_view, follow, post_like, post_comment) and status (pending, sent, accepted, withdrawn). Each action records the target (profileUrl, publicIdentifier, leadId), the note or comment text, the postUrl for likes and comments, the replyTo comment of a reply, the reactionType of a like, and sentAt / acceptedAt / withdrawnAt timestamps. A request that fails returns its error and is not logged, so the list only holds actions that went out.
For push-style tracking, subscribe to webhooks instead of polling: besides connection.accepted, a reply from a lead you acted on directly fires message.received with source: direct (campaign when the conversation belongs to a campaign), and email.received when it comes by email.
Approvals: AI drafts awaiting review
Approvals are the human-review queue for AI-generated content. It is produced by campaigns, not by the direct-action endpoints: a sequence step with config.requireApproval: true generates its content and holds it instead of sending it. Two step types feed the queue, and each has its own endpoints. The API lets you build that review flow into your own tools. An item left undecided for 7 days is marked skipped, and the lead moves on without it.
AI comment drafts
A comment_post step drafts a comment on the lead's latest post and holds it as a pending comment.
Each draft carries the post context (postUrl, postText), the AI prompt, the drafted comment, the lead's identity, and a status: pending, approved, rejected, posted, skipped or failed.
- listPendingComments lists drafts, defaulting to
status=pending(passstatus=allfor every status), filterable bycampaignId. Fetch one with getPendingComment. - editPendingComment replaces the draft text without approving it.
- approvePendingComment approves the draft, optionally with replacement text, and the campaign posts it on its next run.
- rejectPendingComment discards the draft; the lead skips the comment step and continues through the sequence.
- regeneratePendingComment asks the AI to re-draft, optionally with a new instruction, keeping the same post context. Each re-draft uses workspace credits.
Text you pass to edit or approve is trimmed to 1,200 characters. Edit, approve, reject and regenerate only work on a draft that is still pending; on one already decided they return 400 with code: warning-pending-comment-decided.
curl -X POST "https://app.swarmhit.com/api/v1/pending-comments/PENDING_COMMENT_ID/approve" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-d '{ "text": "Great breakdown, especially the point on activation metrics." }'To comment with exact text you control, outside any campaign, use the direct commentLastPost endpoint; no approval step is involved.
AI voice notes
An ai_voice_note step renders the lead's personalized script as audio and holds it as a pending voice note. Each one carries the exact spoken script, a playable audioUrl with its durationMs, the voice it used, the lead's identity, and a status: pending, approved, rejected, sent, skipped or failed.
- listPendingVoiceNotes lists notes, defaulting to
status=pending(passstatus=allfor every status), filterable bycampaignId. Fetch one with getPendingVoiceNote. - approvePendingVoiceNote approves the note as it stands, and the campaign sends it on its next run.
- rejectPendingVoiceNote discards it; the lead continues down the still-no-reply branch without it.
- regeneratePendingVoiceNote re-records the note, optionally rewriting what it says.
There is no endpoint to edit the script on its own. The script is what the stored audio actually says, so the two are always replaced together: rewrite and re-record in one call, then approve once you have listened to the result. A rewritten script can use {{variables}} and is trimmed to 800 characters. As with comments, only a pending note can be approved, rejected or re-recorded (400 with code: warning-pending-voice-note-decided otherwise).
curl -X POST "https://app.swarmhit.com/api/v1/pending-voice-notes/PENDING_VOICE_NOTE_ID/regenerate" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-d '{ "script": "Hey {{firstName}}, quick thought on your launch last week." }'Re-recording new wording uses the workspace's credits. Re-recording wording that was already generated replays the cached audio for free.
Call tasks
A campaign's call step leaves a call task for your team instead of sending anything (see Campaigns). Your team works them in the app's Tasks; these endpoints let a dialer or CRM do the same.
Each task carries:
- the
lead(id,name,profileUrl,phone,email, as the lead is now, so a phone found since shows up); - the
campaign(id,name); - the step's
instructions, with the lead's variables filled in; dueAt, when it runs out (after the step'swaitDays);- a
status:pending,done,skipped,stopped,expiredorclosed.
Once decided, a task also has the outcome, the note and decidedAt. A closed task's lead left the step on its own (a reply, a pause, the campaign paused or deleted, the lead removed); closedReason says which.
- listCallTasks lists tasks newest first. It defaults to
status=pending(passstatus=allfor every status) and filters bycampaignId. Fetch one with getCallTask. - completeCallTask logs the call.
outcomeis required:reachedfor a conversation with the lead,voicemailorno_answer. An optionalnotetakes up to 2,000 characters. - skipCallTask skips it.
- stopCallTask stops the lead's sequence (the lead ends
stopped), with an optionaloutcomeandnote.
Logging or skipping a call moves the lead on to its next step at once, exactly as when someone logs it in the app. A task nobody decides expires at dueAt, and the lead moves on without a call.
Only a pending task can be decided. These calls return 400:
code: warning-call-task-decidedfor a task already decided;code: warning-call-task-not-foundfor an unknown id;code: warning-call-task-stalefor a task whose lead already left the step (the task closes);code: warning-call-task-racedfor a task decided elsewhere at the same moment (the first decision stands);code: warning-call-task-stop-failedwhen stopping, if the lead's sequence can no longer be stopped (the task stayspending).
Subscribe to call_task.pending to hear about each new task. It is the only call task event: none fires when a task is decided (here or in the app), expires or closes. A call on such a task returns warning-call-task-decided or warning-call-task-stale.
curl -X POST "https://app.swarmhit.com/api/v1/call-tasks/CALL_TASK_ID/complete" \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-d '{ "outcome": "reached", "note": "Interested; wants a demo next Tuesday." }'Reference
Direct Actions API
Endpoint-by-endpoint reference for one-off sends, reads and the action log.
Approvals API
List, approve, reject and regenerate AI comment drafts and voice notes awaiting review.
Call Tasks API
List the calls campaigns leave for your team, then log, skip or stop the lead's sequence from each.
LinkedIn safety
The caps and pacing rules that protect your sender accounts.
Webhooks
Get notified when invitations are accepted or replies arrive.