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.
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.
| Thing | Names |
|---|---|
| Account | connection_id in bodies and answers, account_id in queries, account_ids when applying automation defaults, and id in /linkedin/accounts queries |
| Sender | The 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 campaign | At 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 |
| Enrollment | campaign_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 batch | The invitation batch an enrollment follows: batch_id, and existing_batch_id when you enroll or adopt it |
| List | id 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 run | id on the run, sent back as append_id in the query |
| Chunk key | Made by S'more for each chunk of a run. Keys that start with li-list: are kept for it |
| Row | row_id |
| Recipient | One person in one enrollment: id on recipient rows, recipient_id on conversations and emails |
| Lead | member_urn on recipient rows, recipient_urn elsewhere |
| Thread | conversation_urn, empty until a thread exists, and urn on a conversation |
| Message | urn, message_urn, expected_inbound_urn and source_message_urn |
| Typed reply | reply_id, which isn't its run_id |
| Mailbox | id on GET /gmail/accounts and id= in its queries, and mailbox_id in MCP tools, settings bodies and the disconnect answer |
id. send_again answers a new id, with retry_of naming the first | |
| Delivery | There'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 asexpected_revisionwhen you append to it or change it - an enrollment's
revision, which recipient rows carry asbatch_revision - a person's automation,
automation.revisiononview=automation, which is0before the person has a row - the account's monitoring
revision, sent aspolicy_revisionwhen 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
revisionon 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/accounts | linkedin:outreach:read | Any member | The workspace's LinkedIn accounts |
GET /linkedin/accounts?id= | linkedin:outreach:read | Any member. Members get actions empty | One account, as the Settings page shows it |
GET /linkedin/accounts?id=&view=removal | linkedin:accounts:write | Owner or admin | What removing the account would stop. It changes nothing |
POST /linkedin/accounts | linkedin:accounts:write | Owner or admin | Starts signing a new account in to LinkedIn |
POST /linkedin/accounts?id= | linkedin:accounts:write | Owner or admin | Signs an existing account in again |
PATCH /linkedin/accounts?id=&action=continue&challenge_id= | linkedin:accounts:write | Owner or admin | Answers what LinkedIn asked for during a sign-in, such as a code |
PATCH /linkedin/accounts?id=&action=cancel | linkedin:accounts:write | Owner or admin | Cancels a sign-in that's under way |
DELETE /linkedin/accounts?id= | linkedin:accounts:write | Owner or admin | Removes the account |
PATCH /linkedin/accounts?id=&action=request_check | linkedin:automation:write | Owner or admin. No test covers a member's refusal yet | The account's next scheduled check |
PATCH /linkedin/accounts?id=&action=refresh_inbox | linkedin:automation:write, linkedin:inbox:read, linkedin:accounts:write | Owner or admin | Queues 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:
| Field | Rules |
|---|---|
accountKey | Your own name for the account. Required for a new account. With ?id=, leave it out or send the account's own accountKey |
username | The LinkedIn sign-in |
password | The LinkedIn password |
cookies | Optional. An object of cookie names and values from a browser that's signed in |
userAgent | Optional. 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:
| Field | Rules |
|---|---|
verificationCode | The code LinkedIn sent |
resendCode | Optional. true asks for a new code |
checkOnly | Optional. 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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body doesn't fit, or a blank id on sign-in again, removal or its preview |
| 403 | insufficient_scope | The key lacks the row's scope, such as linkedin:accounts:write |
| 403 | personal_connection_required | Only an owner or admin can sign in, verify, cancel or remove, or your role changed since the key was made |
| 403 | admin_required | Only an owner or admin can refresh the inbox, or your role changed since the key was made |
| 404 | connection_not_found | No LinkedIn account with that id in this workspace |
| 404 | not_found | A part of LinkedIn this workspace can't use yet. See Gates |
| 409 | connection_busy | The account is busy, such as with another sign-in. Try again in a minute |
| 409 | connection_sending | A message from the account is still sending, so it wasn't removed yet |
| 409 | nothing_to_cancel | No sign-in is under way |
| 422 | connection_code_not_offered | LinkedIn asked for approval in its app, not a code |
| 422 | challenge_failed | The verification didn't finish. Check challenge and try again |
| 422 | connection_failed | The 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 |
| 503 | connection_unavailable | LinkedIn sign-in isn't available right now |
| 503 | activity_disabled | The 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/campaigns, optional account_id or sender_urn, limit and cursor | linkedin:activity:read | Any member | The workspace's account campaigns |
GET /linkedin/campaigns?view=enrollments, same options | linkedin:activity:read | Any member | Enrollments across the workspace's campaigns |
GET /linkedin/campaigns?id= | linkedin:activity:read | Any member | One account campaign |
GET /linkedin/campaigns?id=&view=batches, optional limit and cursor | linkedin:activity:read | Any member | The campaign's source batches |
GET /linkedin/campaigns?id=&campaign_batch_id= | linkedin:activity:read | Any member. Approved first messages only for an owner or admin whose key holds linkedin:inbox:read | One enrollment with its recipients |
GET /linkedin/campaigns?id=&append_id=, optional progress, limit and cursor | linkedin:activity:read | Any member. Texts as for an enrollment | One list append run |
POST /linkedin/campaigns | linkedin:automation:write, linkedin:inbox:read, linkedin:outreach:send | Owner or admin. No test covers a member's refusal yet | Enrolls 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 invitation | Owner or admin. No test covers a member's refusal yet | Appends up to 100 people to the campaign |
POST /linkedin/campaigns?id=&list_id= | linkedin:automation:write, linkedin:inbox:read, linkedin:outreach:send, outbound:lists:read | Owner or admin | Appends a saved list, one chunk of up to 100 invitations a call |
PATCH /linkedin/campaigns?id= | linkedin:automation:write, plus linkedin:outreach:cancel to cancel | Owner or admin. No test covers a member's refusal yet | Pauses, resumes or cancels the account campaign |
PATCH /linkedin/campaigns?id=&campaign_batch_id= | linkedin:automation:write, plus linkedin:outreach:cancel to cancel | Owner or admin. No test covers a member's refusal yet | Pauses, 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:
| Field | Rules |
|---|---|
connection_id | Required. The account that sent the batch |
existing_batch_id | Required. The invitation batch |
name | Required. Up to 200 bytes |
idempotency_key | Required. Up to 128 bytes |
policy_revision | Required. The account's monitoring revision |
track_until | Required. When S'more stops following these people, in the future and at most 90 days away |
rows | Required. 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_active | Optional. 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:
| Field | Rules |
|---|---|
connection_id | Required. The campaign's account. Another account answers 409 activity_conflict, which no test covers yet |
expected_revision | Required. The campaign's revision |
idempotency_key | Required. 1 to 119 bytes. A key that starts with li-list: answers 400 invalid_request |
policy_revision | Required. The account's monitoring revision |
track_until | Required. As for an enrollment |
rows | Required. 1 to 100 rows, as below |
existing_batch_id | Optional. Adopt this batch instead of sending new invitations |
schedule | Optional, and not with existing_batch_id. local_start_at and time_zone for when the invitations start |
Each row takes:
| Field | Rules |
|---|---|
row_id | Required. Up to 128 bytes |
first_message | Required. The exact text after they accept. Empty tracks that person only |
profile_url | The person's LinkedIn profile. Required for new invitations, and not sent when adopting |
invitation_note | The 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, company | Optional. 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"
}
revisionis the campaign's new revision, theexpected_revisionfor your next append.batch_idis the invitation batch to read atGET /linkedin/outreach?id=, andbatch_enrollment_idis the new enrollment'scampaign_batch_id.added_row_idsare the people this call added.already_present_row_idswere already in the campaign with the same text,conflicting_row_idswith different text, andblocked_row_idsare 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:
| Field | Rules |
|---|---|
connection_id | Required. The campaign's account |
idempotency_key | Required. Up to 128 bytes. It names the run |
expected_revision | Required. The campaign's revision on the first call, then chunk.revision from the last answer |
policy_revision | Required. The account's monitoring revision |
track_until | Required. As for an enrollment. The first call's value is kept |
chunk_index | Required. 0 first, then next_chunk_index |
rows | 1 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_revision | A 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 |
schedule | Optional. 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:
- Send the body with
chunk_index0, the campaign'srevisionasexpected_revisionand the monitoringrevisionaspolicy_revision. - While
statusisopen, send the same body withchunk_indexset tonext_chunk_indexandexpected_revisionset tochunk.revision. - Stop when
statusiscompleteorfailed.
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 theappend_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_conflictand changes nothing, so wait and send the same body again. - If the campaign or the monitoring changed before the chunk started, so its
invites_queuedisfalse, the call answers409 activity_conflictand the run staysopen. Read both revisions again and send the samechunk_indexwith them. - A
409 activity_conflictwith 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_indexahead ofnext_chunk_indexanswers409 activity_conflict, and one past the last chunk answers400 invalid_requestwithfieldchunk_index. - A run whose
statusisfailedended atfailed_chunk_index, andfailure_reasonsays why, such astrack_until_passed,campaign_cancelledorpolicy_changed. Start a new run with a new key for the rest of the list. People an earlier run already added come back asalready_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 chunkpendingandinvites_queuedtrue, and every resend answers409 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 asalready_presentand doesn't invite them again, and one whose invitation issend_unknownasblocked. 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.
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:
| Field | Rules |
|---|---|
status | Required. active, paused or cancelled |
expected_revision | Required. 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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A 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 |
| 403 | insufficient_scope | The 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 |
| 403 | admin_required | Only owners and admins can append a saved list, or your role changed since the key was made |
| 403 | paid_required | New invitations on an append need paid access. No test covers this yet |
| 404 | not_found | No such campaign, enrollment, list or run in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates |
| 409 | activity_conflict | A 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 |
| 503 | activity_disabled | LinkedIn 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/recipients?campaign_id=, optional limit and cursor | linkedin:activity:read | Any member | The recipients of every enrollment in the account campaign |
GET /linkedin/recipients?campaign_id=&campaign_batch_id= | linkedin:activity:read | Any member | One enrollment's recipients, in one answer |
GET /linkedin/recipients?id=&view=automation | linkedin:activity:read | Any member | One person's automated steps |
GET /linkedin/recipients?campaign_id=&view=automation, optional campaign_batch_id, state, limit and cursor | linkedin:activity:read | Any member | A page of the account campaign's recipients, one for each person in each enrollment, with their automated steps |
PATCH /linkedin/recipients?id=&view=automation | linkedin:automation:write, and more for some changes | Owner or admin | Pauses, resumes, opts out or reviews one person's automation |
PATCH /linkedin/recipients?id=&campaign_batch_id=&action=stop | linkedin:automation:write, linkedin:outreach:cancel | Owner or admin. No test covers a member's refusal yet | Stops one person in an enrollment |
PATCH /linkedin/recipients?id=&campaign_batch_id=&action=edit_message | linkedin:automation:write, linkedin:inbox:read, linkedin:outreach:send | Owner or admin. No test covers a member's refusal yet | Replaces one person's first message |
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_idis the person's enrollment, thecampaign_batch_idthat stopping or editing them takes, andbatch_revisionis that enrollment'srevision.message_batch_idis the outreach batch of the person's first message, to read atGET /linkedin/outreach?id=. It's empty until there is one.conversation_urnis 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_idis the account campaign,campaign_batch_idthe enrollment andaccount_idthe account'sconnection_id.automation.revisionis theexpected_revisionof the person's next change. It's0until their first change.automation.activation_statusis the account's automation as it applies to this person, andpaused_atandopted_out_atare this person's own pause and opt-out.next_check_atis the account's next scheduled check.- Each step has
step(after_accept,follow_uporrequest_email),attempt,state,hold_reasonandstop_reason, and, once S'more queues it,outreach_batch_idwithoutreach_statusandoutreach_error_code. Read that batch atGET /linkedin/outreach?id=. next_eligible_atis the earliest a waiting step can go:due_at, or the next opening in the account's working hours whendue_atfalls outside them. The step goes at the first scheduled check after that. It'snullfor 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:
| Field | Rules |
|---|---|
change | Required. pause, resume, opt_out, continue or request_email |
expected_revision | Required. The person's automation.revision |
message_urn | Required 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.
pauseholds the person's steps that haven't started, withhold_reasonrecipient_paused, andresumelets them wait again. Pausing a paused person changes nothing, and resuming one who isn't paused answers409 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 inautoemail mode a paused person can still be emailed.opt_outalso needslinkedin:outreach:cancel. It cancels every step of theirs that hasn't started, withstop_reasonopt_out, and keeps this person in this enrollment out of automation from then on. Opting out again changes nothing, and pausing after it answers409 activity_conflict. The recipient row itself doesn't change. This opt-out isn't checked before an email is scheduled, so inautoemail mode a person who opted out here can still be emailed. Inaskmode the held email doesn't show the opt-out either.continuewith nomessage_urnlets 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 answers409 activity_conflict, and the next check decides.continuewithmessage_urnreviews 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 assuperseded, though no test covers that skip yet. When the person did write it, the email request held for it is skipped assupersededand isn't asked again for that message, though no test covers that case yet.continuenever approves an email.request_emailwithmessage_urntreats the person's newest message, an attachment with no text, as a reply. The email request held for it asattachment_onlywaits to send, which approves asking the person for an email address on LinkedIn later. To decline instead, sendcontinuewith the samemessage_urn. The request is then skipped assuperseded, though no test covers that case yet. While email requests are closed it answers404 not_foundto every caller.continueandrequest_emailalso needlinkedin:inbox:readandlinkedin: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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A 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 |
| 403 | insufficient_scope | The key lacks a scope this mode or change needs, such as linkedin:outreach:cancel to opt out |
| 403 | admin_required | Only owners and admins can change a person's automation, or your role changed since the key was made |
| 404 | not_found | No 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 |
| 409 | revision_conflict | The person's automation changed since you read it, or message_urn isn't their newest message waiting for review |
| 409 | activity_conflict | The 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 |
| 503 | activity_disabled | LinkedIn 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/people, optional account_id, state, q, limit and cursor | linkedin:activity:read | Any member. The draft invitation note and first message only for an owner or admin whose key holds linkedin:inbox:read | The workspace's people and their LinkedIn state |
GET /linkedin/people?list_id=, same options | linkedin:activity:read, outbound:lists:read | Any member | The 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_idnarrows to one account. An account outside this workspace answers400 invalid_request.statetakes 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_sharedoremailed.invite_unknownandmessage_unknownmean LinkedIn didn't confirm the invitation or message.qnarrows to people who match a search term.list_idnarrows to one saved list's members, andcountsthen counts that list. It needsoutbound:lists:readon the key, otherwise403 insufficient_scope. A list this workspace doesn't have answers404 not_found, and so does anylist_idwhile outbound lists are closed.limitis 1 to 100. Sendnext_cursorback ascursor, 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/conversations, optional account_id or sender_urn, limit and cursor | linkedin:inbox:read | Owner or admin. No test covers a member's refusal yet | The threads, each with its latest message |
GET /linkedin/conversations?view=messages&conversation_urn=, with account_id or sender_urn, optional limit and cursor | linkedin:inbox:read | Owner or admin. No test covers a member's refusal yet | One 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 query | Scope | Authority | Does |
|---|---|---|---|
POST /linkedin/outreach | linkedin:outreach:send | Any key with the scope | Submits approved invitations, direct messages and post replies from one account. Submitting is the approval to send |
POST /linkedin/outreach?type=reply | linkedin:outreach:send, linkedin:inbox:read | Owner or admin | Replies in a thread to the person's newest message |
GET /linkedin/outreach?id=, optional cursor and limit | linkedin:outreach:read | Any key with the scope | Reads a batch and a page of its rows |
GET /linkedin/outreach?reply_id= | linkedin:outreach:read | Any key with the scope | Reads a typed reply's delivery |
PATCH /linkedin/outreach?id=&action=cancel | linkedin:outreach:cancel | Any key with the scope | Cancels the rows not yet sent |
PATCH /linkedin/outreach?id=&action=resume | linkedin:outreach:send | Owner or admin. No test covers a member's refusal yet | Lets a batch held after repeated failures send again |
PATCH /linkedin/outreach?id=&action=reconnect | linkedin:outreach:send | Owner or admin | Moves 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:
| Field | Rules |
|---|---|
connection_id | Required. The account's connection_id from GET /linkedin/accounts |
idempotency_key | Required. 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 |
rows | Required. 1 to 500 rows, each with a unique row_id (up to 128 bytes), an action and a message that isn't blank |
schedule | Optional. local_start_at and time_zone for when sending starts |
pacing | Optional. min_delay_seconds and max_delay_seconds between sends |
| Action | Row fields |
|---|---|
connect | profile_url, a LinkedIn profile, and message, the invitation note, up to the account's invitation_note_limit |
dm | profile_url and message, up to 1,000 characters counted in UTF-16 code units |
reply | reply_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:
| Field | Rules |
|---|---|
connection_id | Required. The account in the thread |
conversation_urn | Required. The thread |
expected_inbound_urn | Required. The urn of the person's newest message, from view=messages |
body | Required. The exact reply, up to 1,000 characters counted in UTF-16 code units |
idempotency_key | Required. 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,
codeislinkedin_retry_exhausted,resume_policyisexplicit_after_reviewandresume_revisionis set.next_action.typeisresume_linkedin_outreach. Review the failures, then resume. - When the account needs to sign in again, such as with
codeaccount_disconnected,next_action.typeisreconnect_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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The 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 |
| 403 | insufficient_scope | The key lacks the row's scope, or a typed reply from a key whose person isn't an owner or admin now |
| 403 | admin_required | Only owners and admins can reconnect a batch, or your role changed since the key was made |
| 403 | paid_required | Sending needs paid access. No test covers this on these rows yet |
| 404 | not_found | No LinkedIn batch or reply with that id in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates |
| 409 | idempotency_conflict | That 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 |
| 409 | connection_required | Choose a verified LinkedIn account connected to this workspace |
| 409 | activity_conflict | A typed reply's expected_inbound_urn isn't the person's newest message any more. No test covers this refusal at submit yet |
| 409 | duplicate_reply | This exact reply already went, or is waiting to go, to that message, so it wasn't sent again |
| 409 | outreach_resume_conflict | The batch isn't held at that revision. Read it again before you resume |
| 422 | connection_required | The account for a reconnect isn't usable for this batch |
| 503 | outreach_disabled | LinkedIn outreach isn't available right now. See Gates |
| 503 | activity_disabled | Typed 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
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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/emails, optional account_id or sender_urn, status, conversation_urn, recipient_urn, limit and cursor | gmail:read | Any member. The emails themselves only for an owner or admin whose key holds linkedin:inbox:read, and the counts for everyone else | The workspace's emails |
GET /linkedin/emails?id= | gmail:read, linkedin:inbox:read | Owner or admin | One email |
PATCH /linkedin/emails?id= with action send, edit or send_again | gmail:write, linkedin:inbox:read, gmail:send | The mailbox's owner, who is an owner or admin | Sends, edits or sends again one email |
PATCH /linkedin/emails?id= with action cancel, dismiss or mark | gmail:write, linkedin:inbox:read | Any owner or admin | Cancels, 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_emailis the address the person typed, andother_addressesholds any others in the same reply.source_message_urnis that reply.statusisheld,scheduled,sending,sent,send_unknown,failedorcancelled.hold_reasonsays why an email is held:ask_firstinaskmode,several_addresses,shared_inbox,opt_out_words,late, ormanual_linkedin_replywhen someone wrote to the person on LinkedIn after their reply.send_atis when a scheduled email goes.wait_reasonsays why a scheduled email is waiting, such asoutside_hoursordaily_cap, and can be empty while it waits.can_sendistruefor the mailbox's owner, who alone sends, edits and sends again.revisionis theexpected_revisionof the next action.countscounts the emails by state:waiting_okis heldask_first,heldis held for any other reason,unknownissend_unknown,failedleaves out failed emails someone has already dealt with, andrepliedandbouncedcount marks. A member or a key withoutlinkedin:inbox:readgetsitemsempty 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:
| Field | Rules |
|---|---|
action | Required. send, edit, send_again, cancel, dismiss or mark |
expected_revision | Required. The email's revision |
to_email | Optional, for send. The email's to_email or one of its other_addresses |
subject, body | Required for edit. The exact new text |
mark | Required 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.
sendreleases 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.editreplaces a held email'ssubjectandbodyand keeps it held. A later approval of a new template doesn't rewrite an email you edited.send_againmakes a new email, with a newidandretry_ofnaming the first, for an email whosestatusisfailed, such as one markedwas_not_sent. It's scheduled the waysendschedules 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_addressesanswers400 invalid_emailwithfieldto_email, though no test covers that refusal yet. - An email whose
statusissend_unknowncan't be sent, edited or sent again until someone marks it. Those actions answer409 email_changed.
Cancel, dismiss or mark
cancelstops a held or scheduled email.dismissends a held email without sending it, and its address never reaches the person's contact.markrecords what happened.was_sentandwas_not_sentsettle an email whosestatusissend_unknown.repliedandbouncedrecord 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:
- Check the mailbox's Sent folder.
- If the email is there, send
action=markwithmarkwas_sent. If it isn't, sendmarkwas_not_sent. While Gmail may still be answering for it,was_not_sentanswers409 email_in_flight, so wait a few minutes and send it again. - After
was_not_sent, the mailbox's owner can sendaction=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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | An unknown action, mark or status, a body that doesn't fit, or an account_id that names no LinkedIn account in this workspace |
| 400 | invalid_email | The edit or address can't be used. field names the part, such as to_email |
| 403 | insufficient_scope | The key lacks a scope this mode needs, such as linkedin:inbox:read or gmail:send |
| 403 | admin_required | Only owners and admins read one email or act on it, or your role changed since the key was made |
| 403 | mailbox_owner_required | Only the mailbox's owner sends, edits or sends again |
| 404 | not_found | No such email in this workspace, or a part of LinkedIn this workspace can't use yet. See Gates |
| 409 | revision_conflict | The email changed since you read it |
| 409 | email_changed | The email's state doesn't allow that action now. Read it again |
| 409 | email_in_flight | Gmail 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 |
| 409 | already_emailed | This 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /linkedin/stats?mode=activity&from=&to=, optional account_id or sender_urn, campaign_id or campaign_batch_id, and time_zone | linkedin:activity:read | Any member | What happened in the period, with daily counts |
GET /linkedin/stats?mode=cohort&from=&to=, the same options except time_zone | linkedin:activity:read | Any member | What the people first contacted in the period have done since |
GET /linkedin/stats?mode=automation&from=&to=, optional account_id, campaign_id and time_zone | linkedin:activity:read | Any member | The automated steps due in the period, by state |
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.