Direct actions
Send connection requests, messages, InMails and other one-off LinkedIn actions from a sender account, 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. 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 direct action runs from one sender account, so the endpoints live under /accounts/{id}/.... 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 the lead's last post | POST /accounts/{id}/post-likes | likeLastPost |
| Comment on the lead's last post | POST /accounts/{id}/post-comments | commentLastPost |
| Check connection status | GET /accounts/{id}/connections/{identifier} | checkConnection |
| Look up a profile | GET /accounts/{id}/profiles/{identifier} | lookupProfile |
| List direct actions | GET /accounts/{id}/actions | listOutreachActions |
The connection-status and profile-lookup reads are silent: no profile view is registered on LinkedIn and no cap is used. 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. likeLastPost and commentLastPost act on the lead's most recent post, whatever its age, and fail with 422 when the lead has no post. All four take profileUrl; commentLastPost also takes the exact comment text. On likes and comments, the action's postUrl is the post acted on, and a comment's text is in note.
By default a sender can make 40 profile views, 30 follows, 30 post likes and 15 post comments per day.
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, 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 errored after it may have taken effect | 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, 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 |
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 |
429 | A sender limit was hit, or LinkedIn is rate-limiting the sender. Codes: inmail_credits_exhausted for InMail credits, otherwise none |
502 | LinkedIn 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, 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).
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.
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.
LinkedIn safety
The caps and pacing rules that protect your sender accounts.
Webhooks
Get notified when invitations are accepted or replies arrive.