Skip to main content

LinkedIn API

The /api/v1/linkedin modes for accounts, campaigns, recipients, people, conversations, outreach, emails and stats, with their query, body, answer, scope and errors, and the identifiers every LinkedIn route uses

This page covers the LinkedIn routes under https://app.makesmores.com/api/v1/linkedin: accounts, campaigns, recipients, people, conversations, outreach batches, emails and stats. It also names the identifiers every /api/v1/linkedin route uses. Paths below are relative to https://app.makesmores.com/api/v1. Send Authorization: Bearer <key> on every call and bodies as JSON with Content-Type: application/json. Templates, automation, monitoring and email setup are on LinkedIn settings API.

note

Accounts, campaigns, list appends, recipients, people, conversations, outreach and the activity and cohort stats are enabled in production. Recipient automation (view=automation), the automation stats, a new list append that takes its notes from a template and LinkedIn emails aren't enabled yet. Their sections say what they answer until they are.

On each path the method and the query together pick one mode, and each parameter belongs to the mode it picks. A request that matches no mode answers 400 invalid_request before S'more reads the key, and costs nothing. While LinkedIn is closed, every request to these paths gets the same plain text 404 page not found as a path S'more doesn't serve. API conventions covers these rules and what every route here shares: errors, caching, billing, timeouts, retries and paging. Every mode bills $0.001 per call, and an OAuth grant made for an MCP client gets 401 on every route here.

Reading one account, signing in, verifying, cancelling, the removal preview and removal answer in camelCase, as the Settings page in the S'more app reads them. Every other mode here answers in snake_case. The JSON samples below are shortened, so read fields by name and expect more of them.

Identifiers​

One thing often has a different name on each route. This table maps them, including the names on routes other pages cover.

ThingNames
Accountconnection_id in bodies and answers, account_id in queries, account_ids when applying automation defaults, and id in /linkedin/accounts queries
SenderThe account's LinkedIn identity: sender_urn on most routes, member_urn on GET /linkedin/accounts. On a message, sender_urn is whoever wrote it
Account campaignAt most one for each verified LinkedIn account, made when its monitoring is first saved. id on /linkedin/campaigns, campaign_id on /linkedin/recipients, /linkedin/stats and append answers, and account_campaign_id in MCP tools and on people rows. On /linkedin/people rows, campaign_id is the enrollment instead
Enrollmentcampaign_batch_id in queries, id on view=enrollments and on an enrollment's answer, batch_enrollment_id on append answers, and campaign_id in the older MCP campaign tools
Source batchThe invitation batch an enrollment follows: batch_id, and existing_batch_id when you enroll or adopt it
Listid on a saved list and list.id on an import, id= on /outbound/lists, and list_id on a list append and in MCP tools
List append runid on the run, sent back as append_id in the query
Chunk keyMade by S'more for each chunk of a run. Keys that start with li-list: are kept for it
Rowrow_id
RecipientOne person in one enrollment: id on recipient rows, recipient_id on conversations and emails
Leadmember_urn on recipient rows, recipient_urn elsewhere
Threadconversation_urn, empty until a thread exists, and urn on a conversation
Messageurn, message_urn, expected_inbound_urn and source_message_urn
Typed replyreply_id, which isn't its run_id
Mailboxid on GET /gmail/accounts and id= in its queries, and mailbox_id in MCP tools, settings bodies and the disconnect answer
Emailid. send_again answers a new id, with retry_of naming the first
DeliveryThere's no single id. Read the outbox batch for each kind at GET /linkedin/outreach?id=. Invitations: batch_id or existing_batch_id. A main message: message_batch_id on recipient rows. A follow-up or an email request: steps[].outreach_batch_id on view=automation. A typed reply: run_id, with its status in delivery at GET /linkedin/outreach?reply_id=. An email has no batch, so use its id, status and retry_of. The invitation delivery field on /admin/invites is unrelated

Each of these keeps its own revision, and one can't stand in for another:

  • the account campaign's revision, sent as expected_revision when you append to it or change it
  • an enrollment's revision, which recipient rows carry as batch_revision
  • a person's automation, automation.revision on view=automation, which is 0 before the person has a row
  • the account's monitoring revision, sent as policy_revision when you enroll or append
  • each template slot, the automation defaults, an account's automation, the message after acceptance and the email policy, each described on LinkedIn settings API
  • a mailbox's revision on Gmail accounts
  • an email's revision
  • an outreach batch's resume_revision

Accounts​

An account is a LinkedIn sign-in connected to the workspace. Its connection_id is the id and account_id the routes below take.

Method and queryScopeAuthorityDoes
GET /linkedin/accountslinkedin:outreach:readAny memberThe workspace's LinkedIn accounts
GET /linkedin/accounts?id=linkedin:outreach:readAny member. Members get actions emptyOne account, as the Settings page shows it
GET /linkedin/accounts?id=&view=removallinkedin:accounts:writeOwner or adminWhat removing the account would stop. It changes nothing
POST /linkedin/accountslinkedin:accounts:writeOwner or adminStarts signing a new account in to LinkedIn
POST /linkedin/accounts?id=linkedin:accounts:writeOwner or adminSigns an existing account in again
PATCH /linkedin/accounts?id=&action=continue&challenge_id=linkedin:accounts:writeOwner or adminAnswers what LinkedIn asked for during a sign-in, such as a code
PATCH /linkedin/accounts?id=&action=cancellinkedin:accounts:writeOwner or adminCancels a sign-in that's under way
DELETE /linkedin/accounts?id=linkedin:accounts:writeOwner or adminRemoves the account
PATCH /linkedin/accounts?id=&action=request_checklinkedin:automation:writeOwner or admin. No test covers a member's refusal yetThe account's next scheduled check
PATCH /linkedin/accounts?id=&action=refresh_inboxlinkedin:automation:write, linkedin:inbox:read, linkedin:accounts:writeOwner or adminQueues one extra check of the account's LinkedIn inbox

S'more reads the role the key's person holds now, on each call. A key whose person was an admin and is a member now gets 403 personal_connection_required on the sign-in, verification, cancel and removal rows, and 403 admin_required on the inbox refresh. A key without the row's scope gets 403 insufficient_scope.

Read accounts​

GET /linkedin/accounts lists every account, with no paging:

{
"connect_url": "https://app.makesmores.com/app/settings?platform=linkedin&workspace_id=33333333-3333-4333-8333-333333333333",
"connections": [
{
"account_key": "alex",
"connection_id": "55555555-5555-4555-8555-555555555555",
"member_urn": "urn:li:fsd_profile:alex-kim",
"profile_url": "https://www.linkedin.com/in/alex-kim/",
"display_name": "Alex Kim",
"public_identifier": "alex-kim",
"status": "connected",
"can_send": true,
"invitation_note_limit": 200
}
]
}

connect_url opens the page in the S'more app where a person connects an account. can_send says whether the account can send now. invitation_note_limit is the longest invitation note the workspace can approve, in UTF-16 code units.

GET /linkedin/accounts?id= answers one account in camelCase, the same row the Settings page shows to the caller's role. While LinkedIn waits for a code, it reads:

{
"id": "55555555-5555-4555-8555-555555555555",
"sessionRevision": 2,
"platform": "linkedin",
"accountKey": "alex",
"kind": "user_owned",
"status": "challenge_required",
"challenge": { "id": "c-1", "method": "email", "maskedDestination": "a***@example.com", "expiresAt": "2026-10-30T12:10:00Z" },
"createdAt": "2026-10-30T12:00:00Z",
"updatedAt": "2026-10-30T12:00:00Z",
"state": "needs_code",
"message": "Enter the code LinkedIn sent.",
"actions": ["verify", "cancel", "remove"]
}

Read state, message and actions to decide what to do next, not status. challenge is there while LinkedIn asks for something, and its id is the challenge_id for Verify a sign-in. An id this workspace doesn't have answers 404 connection_not_found. This read stays open while signing in and removing accounts are closed.

Sign in an account​

POST /linkedin/accounts starts a sign-in for a new account, and POST /linkedin/accounts?id= signs that account in again. Both take:

FieldRules
accountKeyYour own name for the account. Required for a new account. With ?id=, leave it out or send the account's own accountKey
usernameThe LinkedIn sign-in
passwordThe LinkedIn password
cookiesOptional. An object of cookie names and values from a browser that's signed in
userAgentOptional. That browser's user agent

The tested sign-in sends accountKey, username and password. No test yet covers which mix of password and cookies LinkedIn accepts. The body can be at most 64 KiB, and a field this table doesn't list answers 400 invalid_request.

A sign-in is work on LinkedIn that S'more queues. The answer is the account as GET ?id= reads it, with 202 while the sign-in runs or waits for a person, and 200 once it has settled. Read GET /linkedin/accounts?id= until state changes, and answer a challenge with Verify a sign-in. Starting a second sign-in of an account that's still signing in answers 409 connection_busy.

Credentials and codes travel only in the body. They don't appear in any answer, log or audit log entry. Each sign-in, code, cancel and removal adds an entry to the audit log.

Sign-in has a longer deadline than the usual 15 seconds. After a timeout or a 5xx, read the account before you send it again.

Verify a sign-in​

PATCH /linkedin/accounts?id=&action=continue&challenge_id= takes the challenge.id from the account in challenge_id, and a body of up to 8 KiB:

FieldRules
verificationCodeThe code LinkedIn sent
resendCodeOptional. true asks for a new code
checkOnlyOptional. true checks whether the person approved the sign-in in the LinkedIn app, with no code

No test covers resendCode or checkOnly over this route yet. The answer is the account, 202 while S'more verifies the sign-in. A code sent when LinkedIn asked for approval in its app answers 422 connection_code_not_offered, and a challenge_id that isn't the current one answers 422 challenge_failed.

This call can take up to 1 minute 45 seconds. If it times out or answers a 5xx, read the account with GET ?id= before you send the code again, because the code may already have reached LinkedIn.

Cancel a sign-in​

PATCH /linkedin/accounts?id=&action=cancel takes no body, or {}. Cancelling an account's first sign-in removes the account and answers null, as an id this workspace doesn't have does. Cancelling a new sign-in of an account that was already connected answers the account. An account with no sign-in under way answers 409 nothing_to_cancel.

Remove an account​

Preview a removal first with GET /linkedin/accounts?id=&view=removal:

{ "platform": "linkedin", "accountKey": "alex", "queuedMessages": 0, "awaitingApproval": 0, "sending": false, "agents": [] }

DELETE /linkedin/accounts?id= removes the account and stops the work queued for it:

{ "removed": true, "cancelledMessages": 0, "refusedPosts": 0 }

Removing it again answers "alreadyRemoved": true, and so does an id from another workspace, which touches nothing. A blank id answers 400 invalid_request. While a message from the account is sending, S'more waits a short time for it, then answers 409 connection_sending if it hasn't finished. No test covers this for LinkedIn accounts yet.

Check the inbox​

PATCH /linkedin/accounts?id=&action=request_check answers the account's next scheduled check, and asking again answers the same run_id:

{ "run_id": "88888888-8888-4888-8888-888888888888", "due_at": "2026-10-30T12:00:00Z", "reason": "scheduled" }

S'more checks a monitored account every 45 to 90 minutes within its working hours, as Monitoring describes.

PATCH /linkedin/accounts?id=&action=refresh_inbox queues one extra check of the account's LinkedIn inbox. It shares its allowance with the Refresh button in the S'more app: 30 checks for each LinkedIn account, shown in remaining, and a wait after each check finishes, shown in available_at. It takes no body.

{ "state": "queued", "run_id": "88888888-8888-4888-8888-888888888888", "remaining": 30 }

remaining counts down when the check runs, not when you ask. While a check is queued or the wait is running, asking again spends nothing and answers the same status, with available_at once there's a wait. The extra check only reads: it sends nothing, and an automated message that's due still waits for the next scheduled check.

Account errors​

StatusCodeMeaning
400invalid_requestThe body doesn't fit, or a blank id on sign-in again, removal or its preview
403insufficient_scopeThe key lacks the row's scope, such as linkedin:accounts:write
403personal_connection_requiredOnly an owner or admin can sign in, verify, cancel or remove, or your role changed since the key was made
403admin_requiredOnly an owner or admin can refresh the inbox, or your role changed since the key was made
404connection_not_foundNo LinkedIn account with that id in this workspace
404not_foundA part of LinkedIn this workspace can't use yet. See Gates
409connection_busyThe account is busy, such as with another sign-in. Try again in a minute
409connection_sendingA message from the account is still sending, so it wasn't removed yet
409nothing_to_cancelNo sign-in is under way
422connection_code_not_offeredLinkedIn asked for approval in its app, not a code
422challenge_failedThe verification didn't finish. Check challenge and try again
422connection_failedThe sign-in didn't finish. Other 422 codes on these rows also mean it didn't finish, so read the account before you start again
503connection_unavailableLinkedIn sign-in isn't available right now
503activity_disabledThe account check isn't available right now. On the check or the inbox refresh it may also mean the account's monitoring is off, which no test covers yet

Campaigns​

A campaign groups an account's invitations and the messages that follow them:

  • A verified LinkedIn account has at most one account campaign. It's set up when the account's monitoring is first saved.
  • An enrollment is one approval inside it: an invitation batch S'more follows, with the exact first message each person gets after they accept, or none to track them only.
  • A source batch is the invitation batch itself.
Method and queryScopeAuthorityDoes
GET /linkedin/campaigns, optional account_id or sender_urn, limit and cursorlinkedin:activity:readAny memberThe workspace's account campaigns
GET /linkedin/campaigns?view=enrollments, same optionslinkedin:activity:readAny memberEnrollments across the workspace's campaigns
GET /linkedin/campaigns?id=linkedin:activity:readAny memberOne account campaign
GET /linkedin/campaigns?id=&view=batches, optional limit and cursorlinkedin:activity:readAny memberThe campaign's source batches
GET /linkedin/campaigns?id=&campaign_batch_id=linkedin:activity:readAny member. Approved first messages only for an owner or admin whose key holds linkedin:inbox:readOne enrollment with its recipients
GET /linkedin/campaigns?id=&append_id=, optional progress, limit and cursorlinkedin:activity:readAny member. Texts as for an enrollmentOne list append run
POST /linkedin/campaignslinkedin:automation:write, linkedin:inbox:read, linkedin:outreach:sendOwner or admin. No test covers a member's refusal yetEnrolls an invitation batch that already exists
POST /linkedin/campaigns?id=linkedin:automation:write, linkedin:inbox:read, plus linkedin:outreach:send for any first message or new invitationOwner or admin. No test covers a member's refusal yetAppends up to 100 people to the campaign
POST /linkedin/campaigns?id=&list_id=linkedin:automation:write, linkedin:inbox:read, linkedin:outreach:send, outbound:lists:readOwner or adminAppends a saved list, one chunk of up to 100 invitations a call
PATCH /linkedin/campaigns?id=linkedin:automation:write, plus linkedin:outreach:cancel to cancelOwner or admin. No test covers a member's refusal yetPauses, resumes or cancels the account campaign
PATCH /linkedin/campaigns?id=&campaign_batch_id=linkedin:automation:write, plus linkedin:outreach:cancel to cancelOwner or admin. No test covers a member's refusal yetPauses, resumes or cancels one enrollment

view=batches and campaign_batch_id don't combine. The list reads take limit 1 to 100. Leaving it out gives a default page size, 50, which no test pins yet. Send next_cursor back as cursor, and stop when there's none. A cursor only works with the filters it came from.

Read campaigns​

account_id narrows a list to one account, and needs to name a verified LinkedIn account in this workspace, so an unknown, unverified or empty one answers 400 invalid_request. sender_urn narrows it the same way. Sending both with a sender_urn that isn't that account's answers 400 invalid_request with field account_id. An account campaign reads:

{
"items": [
{
"id": "77777777-7777-4777-8777-777777777777",
"sender_urn": "urn:li:fsd_profile:alex-kim",
"connection_id": "55555555-5555-4555-8555-555555555555",
"name": "LinkedIn outreach",
"status": "active",
"revision": 4,
"created_at": "2026-10-30T12:00:00Z",
"track_only": false,
"counts": { "batches": 2, "recipients": 40, "invited": 40, "accepted": 12, "messaged": 9, "replied": 3 }
}
],
"next_cursor": "eyJ2IjoxfQ"
}

GET ?id= answers one item. Its revision is the expected_revision an append or change sends. view=enrollments answers enrollments in items the same way, each with its id, account_campaign_id, revision, status, existing_batch_id and track_until.

GET ?id=&campaign_batch_id= answers one enrollment with its recipients, not paged. An enrollment under another account campaign answers 404 not_found:

{
"account_campaign_id": "77777777-7777-4777-8777-777777777777",
"revision": 1,
"id": "66666666-6666-4666-8666-666666666666",
"status": "active",
"existing_batch_id": "99999999-9999-4999-8999-999999999999",
"track_until": "2026-11-29T12:00:00Z",
"recipients": [
{
"id": "12121212-1212-4212-8212-121212121212",
"row_id": "row-1",
"member_urn": "urn:li:fsd_profile:jo-park",
"status": "awaiting_acceptance",
"stop_reason": "",
"invitation_sent_at": "2026-10-30T12:00:00Z",
"message_batch_id": "",
"conversation_urn": "",
"person": { "name": "Jo Park", "title": "Head of Operations", "company": "Example Co", "linkedin_url": "https://www.linkedin.com/in/jo-park/" },
"invitation_note": "Hi Jo, glad to connect.",
"first_message": "Thanks for connecting, Jo.",
"track_only": false
}
],
"track_only": false
}

first_message is the approved text, empty for tracking only, and it's left out unless the key holds linkedin:inbox:read and its person is an owner or admin.

view=batches answers each source batch with its outbox status and progress, and the enrollments that follow it. A batch nobody enrolled, such as one that failed, is listed with enrollments empty:

{
"items": [
{
"batch_id": "99999999-9999-4999-8999-999999999999",
"account_campaign_id": "77777777-7777-4777-8777-777777777777",
"created_at": "2026-10-30T12:00:00Z",
"outreach": { "status": "completed", "progress": { "total": 20, "queued": 0, "sending": 0, "sent": 20, "failed": 0, "send_unknown": 0, "cancelled": 0 } },
"enrollments": []
}
]
}

Enroll an invitation batch​

POST /linkedin/campaigns with no query enrolls an invitation batch that already exists for the account, and approves the exact first message each person gets after they accept. The enrollment joins the account's campaign. It never sends the invitations again. It takes:

FieldRules
connection_idRequired. The account that sent the batch
existing_batch_idRequired. The invitation batch
nameRequired. Up to 200 bytes
idempotency_keyRequired. Up to 128 bytes
policy_revisionRequired. The account's monitoring revision
track_untilRequired. When S'more stops following these people, in the future and at most 90 days away
rowsRequired. 1 to 100 of row_id, a row in that batch, and first_message, the exact text. An empty first_message tracks that person only and never sends them a message
skip_activeOptional. true leaves out people the account already follows and lists them in skipped_row_ids

The answer is the enrollment, as GET ?id=&campaign_batch_id= reads it, plus skipped_row_ids when skip_active left anyone out. Its id is the enrollment's campaign_batch_id. Sending the same key and body again answers the same enrollment. A row that isn't in the batch answers 400 invalid_request. A person the account already follows answers 409 activity_conflict unless skip_active is true, and leaving everyone out answers 409 activity_conflict too.

Append to a campaign​

POST /linkedin/campaigns?id= adds up to 100 people to the account campaign in one call. It either queues new invitations, or adopts an invitation batch that already exists when the body has existing_batch_id. It takes:

FieldRules
connection_idRequired. The campaign's account. Another account answers 409 activity_conflict, which no test covers yet
expected_revisionRequired. The campaign's revision
idempotency_keyRequired. 1 to 119 bytes. A key that starts with li-list: answers 400 invalid_request
policy_revisionRequired. The account's monitoring revision
track_untilRequired. As for an enrollment
rowsRequired. 1 to 100 rows, as below
existing_batch_idOptional. Adopt this batch instead of sending new invitations
scheduleOptional, and not with existing_batch_id. local_start_at and time_zone for when the invitations start

Each row takes:

FieldRules
row_idRequired. Up to 128 bytes
first_messageRequired. The exact text after they accept. Empty tracks that person only
profile_urlThe person's LinkedIn profile. Required for new invitations, and not sent when adopting
invitation_noteThe exact invitation note, up to the account's invitation_note_limit. Required for new invitations, and not sent when adopting
first_name, last_name, title, companyOptional. A row with a name adds the person to the workspace's contacts when no contact has that row or profile yet. They aren't part of the approval

New invitations are work on LinkedIn: S'more queues them, and each first message goes later, after the person accepts. Adopting never sends the invitations again, and adopting rows whose first messages are all empty needs no linkedin:outreach:send. The answer:

{
"campaign_id": "77777777-7777-4777-8777-777777777777",
"batch_id": "abababab-abab-4bab-8bab-abababababab",
"batch_enrollment_id": "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd",
"revision": 5,
"added_row_ids": ["row-1", "row-2"],
"already_present_row_ids": [],
"conflicting_row_ids": [],
"blocked_row_ids": ["row-3"],
"start_at": "2026-10-30T12:00:00Z"
}
  • revision is the campaign's new revision, the expected_revision for your next append.
  • batch_id is the invitation batch to read at GET /linkedin/outreach?id=, and batch_enrollment_id is the new enrollment's campaign_batch_id.
  • added_row_ids are the people this call added. already_present_row_ids were already in the campaign with the same text, conflicting_row_ids with different text, and blocked_row_ids are people S'more won't add from this call.

Sending the same key and body again answers the first result. The same key with a changed body answers 409 activity_conflict. A new key with approvals the campaign already holds answers every row in already_present_row_ids, with no new batch.

The call's deadline is 15 seconds, but the work behind it can take longer. After a timeout or a lost answer, send the identical body with the same key again. A resend while the first call still holds the append answers 409 activity_conflict, so wait and send it again. Read the campaign's revision before you send anything new.

Append a saved list​

POST /linkedin/campaigns?id=&list_id= adds the people on a saved list from Outbound lists to the account campaign, in chunks of up to 100 invitations. One call moves one chunk, and you send the same body each time with only chunk_index and expected_revision changing. It takes:

FieldRules
connection_idRequired. The campaign's account
idempotency_keyRequired. Up to 128 bytes. It names the run
expected_revisionRequired. The campaign's revision on the first call, then chunk.revision from the last answer
policy_revisionRequired. The account's monitoring revision
track_untilRequired. As for an enrollment. The first call's value is kept
chunk_indexRequired. 0 first, then next_chunk_index
rows1 to 1,000 of the list's rows to append. Only the people named join the run. Each has row_id, up to 200 characters, invitation_note, up to the account's invitation_note_limit, and an optional first_message, up to 1,000 characters counted in UTF-16 code units. Send rows, invitation_template_revision or both
invitation_template_revisionA saved revision of the invitation template, used as every row's note. With rows, leave each invitation_note empty. Without rows, the whole list goes with that note and no first message, so nobody has a first message of their own. While the account's automation runs the after_accept step, that step fills it after they accept. Otherwise S'more tracks them only
scheduleOptional. local_start_at and time_zone for when the invitations start

row_id is the row's id on GET /outbound/lists?id=&view=people. The body can be at most 256 KiB. The first call freezes the people and their texts into a run, and later calls use what it froze.

{
"id": "efefefef-efef-4fef-8fef-efefefefefef",
"list_id": "45454545-4545-4545-8545-454545454545",
"account_campaign_id": "77777777-7777-4777-8777-777777777777",
"status": "open",
"rows_total": 252,
"chunks_total": 3,
"chunks_done": 1,
"next_chunk_index": 1,
"failed_chunk_index": null,
"error_code": "",
"failure_reason": "",
"totals": { "pending": 152, "added": 99, "already_present": 1, "conflicting": 0, "blocked": 0, "rejected": 0 },
"chunk": {
"index": 0,
"key": "li-list:efefefef-efef-4fef-8fef-efefefefefef:0",
"status": "done",
"revision": 6,
"invites_queued": true,
"result": { "batch_id": "abababab-abab-4bab-8bab-abababababab", "batch_enrollment_id": "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd", "revision": 6, "added_row_ids": ["row-1"] }
}
}

id is the run's append_id. chunk is the chunk this call named, and chunk.result is its append answer, so chunk.result.batch_enrollment_id is that chunk's campaign_batch_id. To append the whole run:

  1. Send the body with chunk_index 0, the campaign's revision as expected_revision and the monitoring revision as policy_revision.
  2. While status is open, send the same body with chunk_index set to next_chunk_index and expected_revision set to chunk.revision.
  3. Stop when status is complete or failed.

When something goes wrong:

  • After a lost answer or a 5xx, send the same body again. S'more reuses the chunk's stored input, and a chunk whose invitations were queued isn't queued a second time. If you lost the append_id, the identical first body answers the run.
  • An earlier call for the same chunk holds it while it runs, and for a short time after if that call was cut off. A resend in that time answers 409 activity_conflict and changes nothing, so wait and send the same body again.
  • If the campaign or the monitoring changed before the chunk started, so its invites_queued is false, the call answers 409 activity_conflict and the run stays open. Read both revisions again and send the same chunk_index with them.
  • A 409 activity_conflict with the message "This idempotency key already identifies a different list append." means the key belongs to another run. That never clears, so start again with a new key.
  • A chunk_index ahead of next_chunk_index answers 409 activity_conflict, and one past the last chunk answers 400 invalid_request with field chunk_index.
  • A run whose status is failed ended at failed_chunk_index, and failure_reason says why, such as track_until_passed, campaign_cancelled or policy_changed. Start a new run with a new key for the rest of the list. People an earlier run already added come back as already_present.
  • If the account's monitoring changes after a chunk's invitations were queued but before that chunk finished, the run can't finish. It stays open, with that chunk pending and invites_queued true, and every resend answers 409 activity_conflict, because S'more resends a chunk only as it was first sent. Start a new run with a new key, as for a failed run. From reading the code, the new run counts a person from that chunk whose invitation is queued or sent as already_present and doesn't invite them again, and one whose invitation is send_unknown as blocked. Neither run enrolls them in the account campaign. No test covers the new run yet.

There's no way to cancel a run. To leave one where it is, stop calling. No test yet covers appending a list to a campaign that has no enrollment.

note

LinkedIn templates aren't enabled in production yet, so a new run can't take its notes from invitation_template_revision. A run that sends rows with each person's own invitation_note isn't affected.

A new run with invitation_template_revision answers 404 not_found while templates are closed. A run that was already created keeps going on the notes it froze. Each call can take up to 3 minutes, and each call bills.

GET /linkedin/campaigns?id=&append_id= reads a run. It adds every chunk in chunks and a page of the frozen rows in rows, each with row_id, profile_url, chunk_index, progress and reason. progress filters the rows to pending, added, already_present, conflicting, blocked or rejected. Rows page with limit 1 to 100, and you send rows.next_cursor back as cursor. The row texts appear only for an owner or admin whose key holds linkedin:inbox:read.

Pause, resume or cancel​

PATCH /linkedin/campaigns?id= changes the account campaign, and PATCH /linkedin/campaigns?id=&campaign_batch_id= changes one enrollment. Both take only:

FieldRules
statusRequired. active, paused or cancelled
expected_revisionRequired. The campaign's or the enrollment's revision

The answer is the campaign, or the enrollment with its recipients, with revision one higher. A stale expected_revision answers 409 activity_conflict, and an enrollment under another account campaign answers 404 not_found.

  • Pausing the account campaign holds the account's invitations that haven't started sending, including invitation batches outside any enrollment. Resuming lets them go.
  • Pausing or resuming an enrollment changes that enrollment only, not the account campaign's status.
  • Cancelling the account campaign stops every automated message for its people that hasn't gone yet, and a later check doesn't bring them back. A list append run on it fails with campaign_cancelled.

Campaign errors​

StatusCodeMeaning
400invalid_requestA field doesn't fit, and field names it when S'more can, such as account_id, connection_id, row_id or chunk_index. Also a malformed id or cursor
403insufficient_scopeThe key lacks a scope this mode needs, such as linkedin:inbox:read or outbound:lists:read. Enrolling, appending, and pausing, resuming or cancelling answer this too when the key's person isn't an owner or admin now, though no test covers that on these rows yet
403admin_requiredOnly owners and admins can append a saved list, or your role changed since the key was made
403paid_requiredNew invitations on an append need paid access. No test covers this yet
404not_foundNo such campaign, enrollment, list or run in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates
409activity_conflictA revision is stale, the same key came with a different body, the chunk is busy, or the key names another list append. Read the message and the state before you send again
503activity_disabledLinkedIn campaigns aren't available right now

Reads are safe to repeat. A write with an idempotency_key answers its first result when you send the same body again. A change with a stale expected_revision answers 409, including when your own earlier change went through, so read the campaign before you send it again. API conventions covers the rest.

Recipients​

A recipient is one person in one enrollment. Their id is the id these routes take, and the recipient_id on conversations and emails. In the queries below, campaign_id is the account campaign's id from Campaigns and campaign_batch_id is an enrollment's.

Method and queryScopeAuthorityDoes
GET /linkedin/recipients?campaign_id=, optional limit and cursorlinkedin:activity:readAny memberThe recipients of every enrollment in the account campaign
GET /linkedin/recipients?campaign_id=&campaign_batch_id=linkedin:activity:readAny memberOne enrollment's recipients, in one answer
GET /linkedin/recipients?id=&view=automationlinkedin:activity:readAny memberOne person's automated steps
GET /linkedin/recipients?campaign_id=&view=automation, optional campaign_batch_id, state, limit and cursorlinkedin:activity:readAny memberA page of the account campaign's recipients, one for each person in each enrollment, with their automated steps
PATCH /linkedin/recipients?id=&view=automationlinkedin:automation:write, and more for some changesOwner or adminPauses, resumes, opts out or reviews one person's automation
PATCH /linkedin/recipients?id=&campaign_batch_id=&action=stoplinkedin:automation:write, linkedin:outreach:cancelOwner or admin. No test covers a member's refusal yetStops one person in an enrollment
PATCH /linkedin/recipients?id=&campaign_batch_id=&action=edit_messagelinkedin:automation:write, linkedin:inbox:read, linkedin:outreach:sendOwner or admin. No test covers a member's refusal yetReplaces one person's first message
note

Recipient automation isn't enabled in production yet. Until it is, every view=automation mode answers 404 not_found, to read and to change.

Read recipients​

GET ?campaign_id= pages through the recipients of every enrollment in the account campaign. limit is 1 to 100, and a larger one answers 400 invalid_request. Send next_cursor back as cursor, and stop when there's none. A cursor only works with the list it came from. Each item is a recipient row with its enrollment's fields added:

{
"items": [
{
"id": "12121212-1212-4212-8212-121212121212",
"row_id": "row-1",
"member_urn": "urn:li:fsd_profile:jo-park",
"status": "sent",
"stop_reason": "",
"acceptance_observed_at": "2026-10-30T12:00:00Z",
"invitation_sent_at": "2026-10-30T12:00:00Z",
"message_sent_at": "2026-10-30T14:00:00Z",
"message_batch_id": "34343434-3434-4434-8434-343434343434",
"conversation_urn": "urn:li:msg_conversation:jo",
"person": { "name": "Jo Park", "title": "Head of Operations", "company": "Example Co", "linkedin_url": "https://www.linkedin.com/in/jo-park/" },
"track_only": false,
"batch_enrollment_id": "66666666-6666-4666-8666-666666666666",
"batch_id": "99999999-9999-4999-8999-999999999999",
"batch_revision": 1,
"batch_status": "active",
"state": "messaged",
"last_reply_at": null
}
],
"next_cursor": "eyJ2IjoxfQ"
}
  • batch_enrollment_id is the person's enrollment, the campaign_batch_id that stopping or editing them takes, and batch_revision is that enrollment's revision.
  • message_batch_id is the outreach batch of the person's first message, to read at GET /linkedin/outreach?id=. It's empty until there is one.
  • conversation_urn is empty until a thread exists.

No test covers which roles see first_message on this list yet.

GET ?campaign_id=&campaign_batch_id= answers the same recipients that GET /linkedin/campaigns?id=&campaign_batch_id= lists, as a bare JSON array with no paging, and takes no limit or cursor. An enrollment under another account campaign answers 404 not_found.

Read a person's automation​

GET ?id=&view=automation reads one person's automated steps, and GET ?campaign_id=&view=automation reads a page of people. Neither carries message text, so any member can read them:

{
"id": "12121212-1212-4212-8212-121212121212",
"campaign_id": "77777777-7777-4777-8777-777777777777",
"campaign_batch_id": "66666666-6666-4666-8666-666666666666",
"account_id": "55555555-5555-4555-8555-555555555555",
"sender_urn": "urn:li:fsd_profile:alex-kim",
"recipient_urn": "urn:li:fsd_profile:jo-park",
"status": "sent",
"main_source": "explicit",
"automation": {
"activation_status": "active",
"cutoff_at": "2026-10-30T12:00:00Z",
"enrolled": true,
"enrolled_by": "main_sent",
"paused_at": null,
"opted_out_at": null,
"reviewed_message_urn": "",
"revision": 0
},
"next_check_at": "2026-10-30T14:00:00Z",
"steps": [
{
"step": "follow_up",
"attempt": 1,
"state": "waiting",
"hold_reason": "",
"stop_reason": "",
"due_at": "2026-11-02T14:00:00Z",
"due_basis": "main_sent",
"template_name": "follow_up",
"template_revision": 1,
"outreach_batch_id": "",
"outreach_status": "",
"outreach_error_code": "",
"next_eligible_at": "2026-11-02T14:00:00Z"
}
]
}
  • campaign_id is the account campaign, campaign_batch_id the enrollment and account_id the account's connection_id.
  • automation.revision is the expected_revision of the person's next change. It's 0 until their first change.
  • automation.activation_status is the account's automation as it applies to this person, and paused_at and opted_out_at are this person's own pause and opt-out.
  • next_check_at is the account's next scheduled check.
  • Each step has step (after_accept, follow_up or request_email), attempt, state, hold_reason and stop_reason, and, once S'more queues it, outreach_batch_id with outreach_status and outreach_error_code. Read that batch at GET /linkedin/outreach?id=.
  • next_eligible_at is the earliest a waiting step can go: due_at, or the next opening in the account's working hours when due_at falls outside them. The step goes at the first scheduled check after that. It's null for a step that's held, sent or otherwise done.

On the page, campaign_batch_id narrows to one enrollment and state to people with a step in that state: waiting, ready, claimed, sent, send_unknown, not_sent, cancelled, skipped or held. Another state answers 400 invalid_request, and an enrollment under another account campaign answers 404 not_found. limit is 1 to 100. next_cursor is always in the answer and is empty on the last page, so stop when it's "".

Change a person's automation​

PATCH ?id=&view=automation takes:

FieldRules
changeRequired. pause, resume, opt_out, continue or request_email
expected_revisionRequired. The person's automation.revision
message_urnRequired for request_email and optional for continue. The person's newest message

The body can't repeat the person's id. The answer is the person's automation, with automation.revision one higher when the change did something. Each change adds an entry to the audit log that names the change, not any message text.

  • pause holds the person's steps that haven't started, with hold_reason recipient_paused, and resume lets them wait again. Pausing a paused person changes nothing, and resuming one who isn't paused answers 409 activity_conflict. A step S'more had already started waits while the person is paused and goes once after they're resumed. Pausing a person isn't checked before an email is scheduled, so in auto email mode a paused person can still be emailed.
  • opt_out also needs linkedin:outreach:cancel. It cancels every step of theirs that hasn't started, with stop_reason opt_out, and keeps this person in this enrollment out of automation from then on. Opting out again changes nothing, and pausing after it answers 409 activity_conflict. The recipient row itself doesn't change. This opt-out isn't checked before an email is scheduled, so in auto email mode a person who opted out here can still be emailed. In ask mode the held email doesn't show the opt-out either.
  • continue with no message_urn lets a first message that's held because LinkedIn didn't say when the person accepted (acceptance_time_unknown) wait to send, and records them as enrolled. Once LinkedIn's time is known it answers 409 activity_conflict, and the next check decides.
  • continue with message_urn reviews the person's newest message as not a reply. When that message isn't one the person wrote, such as a system message, a follow-up or first message held for it waits again and can then send, and an email request held for review is skipped as superseded, though no test covers that skip yet. When the person did write it, the email request held for it is skipped as superseded and isn't asked again for that message, though no test covers that case yet. continue never approves an email.
  • request_email with message_urn treats the person's newest message, an attachment with no text, as a reply. The email request held for it as attachment_only waits to send, which approves asking the person for an email address on LinkedIn later. To decline instead, send continue with the same message_urn. The request is then skipped as superseded, though no test covers that case yet. While email requests are closed it answers 404 not_found to every caller.
  • continue and request_email also need linkedin:inbox:read and linkedin:outreach:send.

A stale expected_revision, or a message_urn that isn't the person's newest message waiting for review, answers 409 revision_conflict. A change the person's steps don't allow answers 409 activity_conflict. A key without a scope the change needs gets 403 insufficient_scope, and a key whose person is a member now gets 403 admin_required.

Stop or edit one person​

PATCH ?id=&campaign_batch_id=&action=stop takes only expected_revision, the enrollment's revision, which recipient rows carry as batch_revision. It sets the person's stop_reason to user_stopped, and their status to stopped unless it's already sent or send_unknown, which stay as they are, though no test covers that case yet. A message already being sent may still go.

PATCH ?id=&campaign_batch_id=&action=edit_message takes first_message, the exact new text, and expected_revision. It only changes a person whose first message hasn't been queued yet, in an enrollment that's active or paused. It sends nothing itself. It makes your key the approver of every first message in that enrollment that hasn't been sent. An empty first_message tracks the person only, which no test covers on this route yet.

Both answer the enrollment with its recipients, as GET /linkedin/campaigns?id=&campaign_batch_id= reads it, with revision one higher. A stale expected_revision answers 409 activity_conflict. So does an edit for a person whose first message is already queued, sent, stopped or unconfirmed, for a recipient who isn't in that enrollment, or in an enrollment that isn't active or paused. A stop for a recipient who isn't in that enrollment answers 400 invalid_request. No test covers those refusals on these routes yet. A stop with a key that lacks linkedin:inbox:read answers with every first_message left out.

Recipient errors​

StatusCodeMeaning
400invalid_requestA malformed id, an unknown state, a limit over 100, a cursor from another list, or a body that doesn't fit, such as one that repeats the id
403insufficient_scopeThe key lacks a scope this mode or change needs, such as linkedin:outreach:cancel to opt out
403admin_requiredOnly owners and admins can change a person's automation, or your role changed since the key was made
404not_foundNo such recipient on a view=automation read or change, an enrollment that isn't under that account campaign, or a part of LinkedIn this workspace can't use yet. See Gates
409revision_conflictThe person's automation changed since you read it, or message_urn isn't their newest message waiting for review
409activity_conflictThe person's steps don't allow that change, the enrollment changed since you read it, or the person or enrollment can't take that edit
503activity_disabledLinkedIn campaigns aren't available right now

People​

People are the workspace's contacts that have a LinkedIn profile, each with their LinkedIn state, whether or not anyone has invited them yet.

Method and queryScopeAuthorityDoes
GET /linkedin/people, optional account_id, state, q, limit and cursorlinkedin:activity:readAny member. The draft invitation note and first message only for an owner or admin whose key holds linkedin:inbox:readThe workspace's people and their LinkedIn state
GET /linkedin/people?list_id=, same optionslinkedin:activity:read, outbound:lists:readAny memberThe same, for the members of one saved list
{
"items": [
{
"workflow_state": "replied",
"state": "replied",
"contact_id": "78787878-7878-4878-8878-787878787878",
"row_id": "row-1",
"name": "Jo Park",
"title": "Head of Operations",
"company": "Example Co",
"linkedin_url": "https://www.linkedin.com/in/jo-park/",
"accepted": true,
"replied": true,
"needs_reply": false,
"invited_at": "2026-10-30T12:00:00Z",
"messaged_at": "2026-10-30T14:00:00Z",
"replied_at": "2026-10-30T15:00:00Z",
"campaign_id": "66666666-6666-4666-8666-666666666666",
"account_campaign_id": "77777777-7777-4777-8777-777777777777",
"batch_id": "99999999-9999-4999-8999-999999999999"
}
],
"next_cursor": "eyJ2IjoxfQ",
"counts": { "replied": 1 }
}

On these rows campaign_id is the enrollment, the campaign_batch_id other routes take, and account_campaign_id is the account campaign. row_id is the person's row on the saved list, the same row_id a list append takes.

  • account_id narrows to one account. An account outside this workspace answers 400 invalid_request.
  • state takes one state or several, comma separated: not_contacted, invite_queued, invite_sent, invite_failed, invite_unknown, accepted, message_queued, messaged, replied, stopped, message_unknown, email_shared or emailed. invite_unknown and message_unknown mean LinkedIn didn't confirm the invitation or message.
  • q narrows to people who match a search term.
  • list_id narrows to one saved list's members, and counts then counts that list. It needs outbound:lists:read on the key, otherwise 403 insufficient_scope. A list this workspace doesn't have answers 404 not_found, and so does any list_id while outbound lists are closed.
  • limit is 1 to 100. Send next_cursor back as cursor, and stop when there's none. A cursor only works with the filters it came from.

Conversations​

Conversations are the LinkedIn threads S'more has read from the workspace's accounts.

Method and queryScopeAuthorityDoes
GET /linkedin/conversations, optional account_id or sender_urn, limit and cursorlinkedin:inbox:readOwner or admin. No test covers a member's refusal yetThe threads, each with its latest message
GET /linkedin/conversations?view=messages&conversation_urn=, with account_id or sender_urn, optional limit and cursorlinkedin:inbox:readOwner or admin. No test covers a member's refusal yetOne thread's messages
{
"items": [
{
"urn": "urn:li:msg_conversation:jo",
"sender_urn": "urn:li:fsd_profile:alex-kim",
"recipient_urn": "urn:li:fsd_profile:jo-park",
"recipient_id": "12121212-1212-4212-8212-121212121212",
"latest_at": "2026-10-30T15:00:00Z",
"workflow_state": "replied",
"needs_reply": true,
"has_inbound": true,
"person": { "name": "Jo Park", "title": "Head of Operations", "company": "Example Co", "linkedin_url": "https://www.linkedin.com/in/jo-park/" },
"last_message": {
"urn": "urn:li:msg_message:jo-1",
"conversation_urn": "urn:li:msg_conversation:jo",
"sender_urn": "urn:li:fsd_profile:jo-park",
"body": "Happy to chat. Tuesday?",
"kind": "human",
"direction": "inbound",
"sent_at": "2026-10-30T15:00:00Z",
"has_attachment": false
}
}
]
}

A thread's urn is the conversation_urn other routes take. A person who accepted an invitation before any thread exists is listed with urn empty. recipient_id is their recipient row when S'more follows them, and email, when there is one, is their latest email with its id, status and hold_reason.

view=messages answers the thread's messages in items, each with urn, conversation_urn, sender_urn, recipient_urn, body, kind, direction, sent_at and has_attachment. On a message, sender_urn is whoever wrote it. The answer adds inbox_complete and checked_at, which tell you how current S'more's copy of the thread is. A thread S'more hasn't read answers 200 with no items. The urn of the person's newest message is the expected_inbound_urn a typed reply takes.

Both reads take limit 1 to 100. Send next_cursor back as cursor, and stop when there's none.

Outreach​

An outreach batch is approved LinkedIn work that S'more sends later from one account. You submit invitations, direct messages and replies to posts here, and S'more's own automated messages and list appends queue batches you read here too. Identifiers says where each kind of batch id comes from.

Method and queryScopeAuthorityDoes
POST /linkedin/outreachlinkedin:outreach:sendAny key with the scopeSubmits approved invitations, direct messages and post replies from one account. Submitting is the approval to send
POST /linkedin/outreach?type=replylinkedin:outreach:send, linkedin:inbox:readOwner or adminReplies in a thread to the person's newest message
GET /linkedin/outreach?id=, optional cursor and limitlinkedin:outreach:readAny key with the scopeReads a batch and a page of its rows
GET /linkedin/outreach?reply_id=linkedin:outreach:readAny key with the scopeReads a typed reply's delivery
PATCH /linkedin/outreach?id=&action=cancellinkedin:outreach:cancelAny key with the scopeCancels the rows not yet sent
PATCH /linkedin/outreach?id=&action=resumelinkedin:outreach:sendOwner or admin. No test covers a member's refusal yetLets a batch held after repeated failures send again
PATCH /linkedin/outreach?id=&action=reconnectlinkedin:outreach:sendOwner or adminMoves a paused batch to an account signed in as the same LinkedIn member

Submitting, reading and cancelling a batch check the key's scope, not its person's role. No test covers a member's key on those rows yet. There's no list of batches: GET /linkedin/outreach needs id or reply_id.

Submit a batch​

POST /linkedin/outreach takes no query:

FieldRules
connection_idRequired. The account's connection_id from GET /linkedin/accounts
idempotency_keyRequired. Up to 128 bytes. Keys that start with li-intro:, li-reply:, li-step:, li-list: or another prefix S'more keeps for its own work answer 400 invalid_request
rowsRequired. 1 to 500 rows, each with a unique row_id (up to 128 bytes), an action and a message that isn't blank
scheduleOptional. local_start_at and time_zone for when sending starts
pacingOptional. min_delay_seconds and max_delay_seconds between sends
ActionRow fields
connectprofile_url, a LinkedIn profile, and message, the invitation note, up to the account's invitation_note_limit
dmprofile_url and message, up to 1,000 characters counted in UTF-16 code units
replyreply_url, a LinkedIn post, and message, up to 1,000 characters counted in UTF-16 code units, with an optional parent_comment_urn to answer a comment on it

A row can't carry another action's fields. S'more stores each message exactly as you send it. The answer is the batch without its rows:

{
"platform": "linkedin",
"schema_version": 1,
"batch_id": "33333333-3333-4333-8333-333333333333",
"workspace_id": "11111111-1111-4111-8111-111111111111",
"status": "queued",
"sender": {
"connection_id": "55555555-5555-4555-8555-555555555555",
"submitted_connection_id": "55555555-5555-4555-8555-555555555555",
"account_key": "alex",
"member_urn": "urn:li:fsd_profile:alex-kim"
},
"schedule": { "start_at": "2026-10-30T12:00:00Z" },
"pacing": { "min_delay_seconds": 45, "max_delay_seconds": 240 },
"expires_at": "2026-11-06T12:00:00Z",
"progress": { "total": 1, "queued": 1, "sending": 0, "sent": 0, "failed": 0, "send_unknown": 0, "cancelled": 0 },
"next_send_at": "2026-10-30T12:00:00Z",
"next_action": null,
"resume_policy": "automatic_after_same_account_reconnect"
}

The same idempotency_key with the same content answers the original batch and queues nothing again. The same key with changed content answers 409 idempotency_conflict. A new key is new work and can send again, even to a person a batch already reached. S'more doesn't promise exactly-once delivery, and Retries explains when a row can repeat. Don't rely on an opt-out recorded in LinkedIn automation to stop a row you submit here.

Reply in a thread​

POST /linkedin/outreach?type=reply answers the person's newest message in a thread. It takes:

FieldRules
connection_idRequired. The account in the thread
conversation_urnRequired. The thread
expected_inbound_urnRequired. The urn of the person's newest message, from view=messages
bodyRequired. The exact reply, up to 1,000 characters counted in UTF-16 code units
idempotency_keyRequired. 1 to 128 bytes
{ "reply_id": "89898989-8989-4989-8989-898989898989", "run_id": "90909090-9090-4090-8090-909090909090" }

The reply records your key as its approver. If the person writes again before the reply goes, S'more doesn't send it. Another reply with the same exact text to the same message is refused as duplicate_reply. A reply with different text is new work and can reach the person too, so read the thread before you write again.

GET /linkedin/outreach?reply_id= reads the reply's delivery. It never carries the reply's text:

{
"reply_id": "89898989-8989-4989-8989-898989898989",
"conversation_urn": "urn:li:msg_conversation:jo",
"expected_inbound_urn": "urn:li:msg_message:jo-1",
"run_id": "90909090-9090-4090-8090-909090909090",
"delivery": "queued",
"created_at": "2026-10-30T15:30:00Z"
}

delivery is queued, sending, sent, send_unknown, failed or cancelled. run_id is the reply's outreach batch. When it's empty, delivery is failed and the reply never reached LinkedIn: sending the same key and body again submits it again, though no test covers that resend yet. send_unknown means LinkedIn didn't confirm the reply, and S'more never sends it again on its own. Read the thread before you write to the person again.

Read a batch​

GET /linkedin/outreach?id= takes a batch_id. limit is 1 to 100 rows a page, and a larger one answers 400 invalid_request. Send next_cursor back as cursor, and stop when there's none. Reading a batch is safe to repeat.

The answer is the batch with rows and next_cursor. Each row has row_id, action, message, status and attempts, the fields you sent, and error_code and failure when it failed. failure explains what S'more recorded, with reason, delivery_state, retry_policy, message and next_action. The text of a batch that S'more's automation queued shows only to an owner or admin whose key holds linkedin:inbox:read. next_send_at is when the next row can go, and next_send_reason says why it waits then, such as ready or daily_invitation_limit.

A row whose status is send_unknown is one LinkedIn didn't confirm, and S'more never sends it again on its own. Read the conversation to see whether it went, and don't submit it again.

When a batch waits, status is paused and next_action says why:

  • After repeated failures from the account, code is linkedin_retry_exhausted, resume_policy is explicit_after_review and resume_revision is set. next_action.type is resume_linkedin_outreach. Review the failures, then resume.
  • When the account needs to sign in again, such as with code account_disconnected, next_action.type is reconnect_linkedin.
{
"type": "reconnect_linkedin",
"url": "https://app.makesmores.com/app/settings?outreach_batch_id=33333333-3333-4333-8333-333333333333&platform=linkedin&workspace_id=11111111-1111-4111-8111-111111111111",
"requires_role": "owner_or_admin"
}

next_action.url points at the S'more app, not at this API. Over the API, send action=resume or action=reconnect instead.

Cancel a batch​

PATCH ?id=&action=cancel takes no body, or {}. It answers the batch without rows, and progress.cancelled counts the rows it cancelled. Cancelling again answers the same batch. A row already sent can't be taken back, and one being sent as you cancel may still go.

Resume or reconnect a batch​

PATCH ?id=&action=resume takes expected_revision, the batch's resume_revision. It only releases a batch that's paused with linkedin_retry_exhausted, and the account's limits and send spacing still apply. Any other batch, or a stale revision, answers 409 outreach_resume_conflict.

PATCH ?id=&action=reconnect takes connection_id, the account to send from now, and expected_connection_id, the account the batch has now. The new account has to be signed in as the same LinkedIn member the batch was approved for. S'more checks that before it moves the batch, and refuses another member with 422 connection_required, though no test covers that refusal on this route yet. If the batch's account isn't expected_connection_id any more, it answers 409 idempotency_conflict, so read the batch again first, though no test covers that refusal yet. A batch this workspace doesn't have answers 404 not_found. Reconnecting is closed whenever signing in accounts is, and then answers 404 not_found.

Outreach errors​

StatusCodeMeaning
400invalid_requestThe body doesn't fit the fields above, the key starts with a prefix kept for S'more, or a bad id, limit or cursor. Nothing is queued
403insufficient_scopeThe key lacks the row's scope, or a typed reply from a key whose person isn't an owner or admin now
403admin_requiredOnly owners and admins can reconnect a batch, or your role changed since the key was made
403paid_requiredSending needs paid access. No test covers this on these rows yet
404not_foundNo LinkedIn batch or reply with that id in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates
409idempotency_conflictThat key already names a batch with different content, or, on a reconnect, the batch moved to another account since you read it. Read the batch again
409connection_requiredChoose a verified LinkedIn account connected to this workspace
409activity_conflictA typed reply's expected_inbound_urn isn't the person's newest message any more. No test covers this refusal at submit yet
409duplicate_replyThis exact reply already went, or is waiting to go, to that message, so it wasn't sent again
409outreach_resume_conflictThe batch isn't held at that revision. Read it again before you resume
422connection_requiredThe account for a reconnect isn't usable for this batch
503outreach_disabledLinkedIn outreach isn't available right now. See Gates
503activity_disabledTyped replies aren't available right now

Read a batch before you act on it again, and never resubmit a row whose status is send_unknown.

Emails​

note

LinkedIn email isn't enabled in production yet, and neither are the Gmail accounts it sends from. Until they are, every mode of /linkedin/emails answers 404 not_found. A key without the mode's Gmail scope gets 403 insufficient_scope first, and API keys says when the Gmail scopes can be given.

When a person types an email address in a LinkedIn reply and the account's email is approved, S'more writes the approved template to that address as an email, which sends from the account's paired Gmail mailbox. These routes read those emails and act on them.

S'more emails only an address the person typed in their reply. An email address on an imported list or a contact is never a send target, and importing a list queues no email. In ask mode every email waits for the mailbox owner to send it. In auto mode an email can be scheduled on its own, and an opt-out or pause recorded in LinkedIn automation isn't checked before that, so a person who opted out or was paused there can still be emailed.

Method and queryScopeAuthorityDoes
GET /linkedin/emails, optional account_id or sender_urn, status, conversation_urn, recipient_urn, limit and cursorgmail:readAny member. The emails themselves only for an owner or admin whose key holds linkedin:inbox:read, and the counts for everyone elseThe workspace's emails
GET /linkedin/emails?id=gmail:read, linkedin:inbox:readOwner or adminOne email
PATCH /linkedin/emails?id= with action send, edit or send_againgmail:write, linkedin:inbox:read, gmail:sendThe mailbox's owner, who is an owner or adminSends, edits or sends again one email
PATCH /linkedin/emails?id= with action cancel, dismiss or markgmail:write, linkedin:inbox:readAny owner or adminCancels, dismisses or records what happened to one email

Read emails​

{
"items": [
{
"id": "abcdabcd-abcd-4bcd-8bcd-abcdabcdabcd",
"sender_urn": "urn:li:fsd_profile:alex-kim",
"recipient_id": "12121212-1212-4212-8212-121212121212",
"recipient_urn": "urn:li:fsd_profile:jo-park",
"person_name": "Jo Park",
"conversation_urn": "urn:li:msg_conversation:jo",
"source_message_urn": "urn:li:msg_message:jo-2",
"mailbox_id": "44444444-4444-4444-8444-444444444444",
"from_email": "alex@example.com",
"to_email": "jo@example.com",
"other_addresses": [],
"subject": "Great to connect, Jo",
"body": "Hi Jo,\n\nThanks for sharing your email.\n\nAlex Kim\n",
"status": "held",
"hold_reason": "ask_first",
"failure_code": "",
"wait_reason": "",
"send_at": null,
"sent_at": null,
"retry_of": null,
"can_send": true,
"revision": 1
}
],
"counts": { "waiting_ok": 1, "held": 0, "scheduled": 0, "sending": 0, "sent": 0, "unknown": 0, "failed": 0, "cancelled": 0, "replied": 0, "bounced": 0 }
}
  • to_email is the address the person typed, and other_addresses holds any others in the same reply. source_message_urn is that reply.
  • status is held, scheduled, sending, sent, send_unknown, failed or cancelled.
  • hold_reason says why an email is held: ask_first in ask mode, several_addresses, shared_inbox, opt_out_words, late, or manual_linkedin_reply when someone wrote to the person on LinkedIn after their reply.
  • send_at is when a scheduled email goes. wait_reason says why a scheduled email is waiting, such as outside_hours or daily_cap, and can be empty while it waits.
  • can_send is true for the mailbox's owner, who alone sends, edits and sends again. revision is the expected_revision of the next action.
  • counts counts the emails by state: waiting_ok is held ask_first, held is held for any other reason, unknown is send_unknown, failed leaves out failed emails someone has already dealt with, and replied and bounced count marks. A member or a key without linkedin:inbox:read gets items empty with the counts.

account_id or sender_urn narrows to one account, and an account outside this workspace answers 400 invalid_request. status takes one status or several, comma separated. limit is 1 to 200, 100 when you leave it out, though no test pins either yet. Send next_cursor back as cursor, and stop when there's none.

GET ?id= answers one email as the list shows it to an owner or admin. Without linkedin:inbox:read it answers 403 insufficient_scope, and to a member 403 admin_required.

Send, edit or send again​

PATCH /linkedin/emails?id= takes action and expected_revision, the email's revision, and for some actions more:

FieldRules
actionRequired. send, edit, send_again, cancel, dismiss or mark
expected_revisionRequired. The email's revision
to_emailOptional, for send. The email's to_email or one of its other_addresses
subject, bodyRequired for edit. The exact new text
markRequired for mark. was_sent, was_not_sent, replied or bounced

Only the mailbox's owner sends, edits or sends again, and anyone else gets 403 mailbox_owner_required. Each answers the email.

  • send releases a held email to the address it names. It's scheduled to go a few minutes later, within the account's hours, and S'more saves the address on the person's contact if the contact has none. This approves this one email to this one address.
  • edit replaces a held email's subject and body and keeps it held. A later approval of a new template doesn't rewrite an email you edited.
  • send_again makes a new email, with a new id and retry_of naming the first, for an email whose status is failed, such as one marked was_not_sent. It's scheduled the way send schedules a held email, a few minutes later within the account's hours.
  • An address that isn't the email's own or one of its other_addresses answers 400 invalid_email with field to_email, though no test covers that refusal yet.
  • An email whose status is send_unknown can't be sent, edited or sent again until someone marks it. Those actions answer 409 email_changed.

Cancel, dismiss or mark​

  • cancel stops a held or scheduled email.
  • dismiss ends a held email without sending it, and its address never reaches the person's contact.
  • mark records what happened. was_sent and was_not_sent settle an email whose status is send_unknown. replied and bounced record an answer to an email that was sent, which no test covers yet.

When S'more can't tell whether an email went​

status send_unknown means S'more can't tell whether Gmail sent the email. S'more never sends it again on its own. To settle it:

  1. Check the mailbox's Sent folder.
  2. If the email is there, send action=mark with mark was_sent. If it isn't, send mark was_not_sent. While Gmail may still be answering for it, was_not_sent answers 409 email_in_flight, so wait a few minutes and send it again.
  3. After was_not_sent, the mailbox's owner can send action=send_again.

If Gmail's confirmation of the first email arrives after that, S'more records the first email as sent and cancels the new one. If the first email did go and no confirmation ever comes, sending again delivers a second email. S'more doesn't promise exactly-once delivery.

Email errors​

StatusCodeMeaning
400invalid_requestAn unknown action, mark or status, a body that doesn't fit, or an account_id that names no LinkedIn account in this workspace
400invalid_emailThe edit or address can't be used. field names the part, such as to_email
403insufficient_scopeThe key lacks a scope this mode needs, such as linkedin:inbox:read or gmail:send
403admin_requiredOnly owners and admins read one email or act on it, or your role changed since the key was made
403mailbox_owner_requiredOnly the mailbox's owner sends, edits or sends again
404not_foundNo such email in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates
409revision_conflictThe email changed since you read it
409email_changedThe email's state doesn't allow that action now. Read it again
409email_in_flightGmail may still be answering for this email, so was_not_sent and send_again wait. Try again in a few minutes. No test covers this refusal for send_again yet
409already_emailedThis mailbox already emailed this person or address, so it won't send again

A write that timed out or lost its answer may still have gone through, so read the email again before you resend, and send the action with the revision you read.

Stats​

Method and queryScopeAuthorityDoes
GET /linkedin/stats?mode=activity&from=&to=, optional account_id or sender_urn, campaign_id or campaign_batch_id, and time_zonelinkedin:activity:readAny memberWhat happened in the period, with daily counts
GET /linkedin/stats?mode=cohort&from=&to=, the same options except time_zonelinkedin:activity:readAny memberWhat the people first contacted in the period have done since
GET /linkedin/stats?mode=automation&from=&to=, optional account_id, campaign_id and time_zonelinkedin:activity:readAny memberThe automated steps due in the period, by state
note

Automation stats aren't enabled in production yet. Until they are, mode=automation answers 404 not_found.

from and to are times such as 2026-10-01T00:00:00Z. campaign_id is an account campaign and campaign_batch_id an enrollment. An account campaign that isn't the named account's answers 400 invalid_request with field campaign_id, and so does a malformed one on mode=activity or mode=cohort with an account named. An account outside this workspace answers 400 invalid_request with no field, and so does a malformed account campaign with no account named or on mode=automation, though no test covers that case yet. from and to can be at most 366 days apart, and time_zone is a zone name such as America/New_York, which mode=cohort doesn't take. No test covers those refusals over these routes yet.

mode=activity and mode=cohort answer the same fields:

{
"invitation_outcomes": { "invited": 40, "accepted": 12, "replied": 3, "acceptance_rate": 0.3, "reply_rate": 0.075 },
"daily": [],
"workflow": { "queued_invitations": 0, "awaiting_acceptance": 28, "awaiting_reply": 6, "needs_reply": 1, "send_unknown": 0, "incomplete_inbox": 0, "pending_checks": 0, "next_check_at": "2026-10-30T16:00:00Z" },
"invitations_sent": 40,
"accepted_connections": 12,
"initial_messages_sent": 9,
"replies_sent": 2,
"invitation_cohort": 40,
"accepted_in_cohort": 12,
"as_of": "2026-10-30T15:30:00Z",
"filters": { "sender_urn": "urn:li:fsd_profile:alex-kim", "from": "2026-10-01T00:00:00Z", "to": "2026-10-31T00:00:00Z", "mode": "activity" },
"last_success_at": "2026-10-30T15:30:00Z",
"connections_checked_at": "2026-10-30T15:30:00Z",
"inbox_checked_at": "2026-10-30T15:30:00Z"
}

filters echoes the request with the names S'more uses inside: the account campaign you sent as campaign_id comes back as account_campaign_id, and campaign_batch_id comes back as campaign_id.

replies_sent counts typed replies only, not follow-ups or email requests. These counts are only as current as S'more's last look at LinkedIn: as_of is when they were read, last_success_at, connections_checked_at and inbox_checked_at are when S'more last checked the account, and workflow counts where people are now, including checks still pending and inboxes not fully read.

mode=automation counts each automated step due in the period once, by its state now:

{
"filters": { "from": "2026-10-01T00:00:00Z", "to": "2026-10-31T00:00:00Z", "connection_id": "55555555-5555-4555-8555-555555555555" },
"after_accept": { "scheduled": 2, "sent": 12, "send_unknown": 0, "held": 0, "cancelled": 0, "skipped": 1 },
"follow_up": { "scheduled": 4, "sent": 5, "send_unknown": 0, "held": 0, "cancelled": 0, "skipped": 0 },
"request_email": { "scheduled": 0, "sent": 2, "send_unknown": 0, "held": 1, "cancelled": 0, "skipped": 0 },
"holds": { "attachment_only": 1 }
}

Here filters.connection_id is the account_id you sent and filters.account_campaign_id is the campaign_id.

scheduled counts steps waiting, ready or under way, cancelled includes steps that ended unsent, and holds counts held steps by reason. These counts have no freshness field, so read the account's monitoring for when S'more last checked it. The automation stats open and close with account automation, and answer 404 not_found while it's closed.

Stats reads are safe to repeat and take no paging.