Skip to main content

Take a LinkedIn list through to email

Import a list, invite it from a LinkedIn account, follow up on LinkedIn and email the people who share an address, with the mailbox owner sending each email

This guide takes a CSV of LinkedIn profiles through the Lists and Gmail API, the LinkedIn settings API and the LinkedIn API, in order. S'more invites the people on the list from one LinkedIn account. After someone accepts, it sends them the first message and at most one follow-up. When someone types an email address in a LinkedIn reply, S'more writes them an email that waits for the mailbox owner to send it. Each call costs $0.001 per call. A step saves what later steps need to files in the current directory, so run every step in one directory.

note

This guide can't be followed in production yet. It needs LinkedIn templates, automation, email requests, Gmail accounts and LinkedIn email, none of which is enabled yet. Until they are, the mailbox owner's sign-in answers 503 outreach_disabled, as API keys says, and the steps that use them answer as Gates lists, most with 404. Steps 1 to 5 and step 12 work now with the admin sign-in alone, so ask only for the admin code until then.

Email here runs in ask mode only, so a person reads every email before it goes. This guide doesn't cover auto mode, because an opt-out recorded in LinkedIn automation isn't checked before an email is scheduled or sent. Nobody has run this flow end to end against a live workspace yet.

Before you start​

  • An owner or admin of the workspace for the admin sign-in below.
  • The person whose own Gmail will send the emails, who is the workspace owner or an admin. If they're an admin, the workspace owner also confirms their mailbox in step 10.
  • A LinkedIn account connected to the workspace, or its sign-in details for step 4.
  • A workspace that can connect Gmail: once Gmail accounts are enabled, GET /gmail/accounts answers available connect there, and coming_soon means it can't connect yet.
  • Outbound lists, Gmail and LinkedIn open, with the LinkedIn templates, automation, email requests, list appends and email this guide uses. Gates lists what each closed one answers.
  • Paid access, if new invitations need it, as Campaign errors says.
  • A CSV with a header row and a LinkedIn profile URL on each row, saved as people.csv.
  • curl, and jq 1.6 or later.

Sign in twice​

The steps use two credentials, each from a device sign-in over the device flow over HTTP. A key from the console can't carry admin:write, which saving templates and activating automation need, as Keys for workspace administration explains.

Sign-inSaved toScopesApproved by
Adminadmin.jsonoutbound:lists:read, outbound:lists:write, linkedin:activity:read, linkedin:outreach:read, linkedin:automation:write, linkedin:inbox:read, linkedin:outreach:send, linkedin:accounts:write, admin:writeAn owner or admin
Mailbox ownerowner.jsonlinkedin:activity:read, linkedin:inbox:read, linkedin:outreach:read, linkedin:automation:write, linkedin:outreach:send, linkedin:outreach:cancel, gmail:read, gmail:write, gmail:sendThe person whose Gmail sends the emails

Ask for both codes:

curl -s https://app.makesmores.com/api/v1/integrations/device/code \
-H "Content-Type: application/json" \
-d '{"client_label": "linkedin-to-email-admin", "scopes": ["outbound:lists:read", "outbound:lists:write", "linkedin:activity:read", "linkedin:outreach:read", "linkedin:automation:write", "linkedin:inbox:read", "linkedin:outreach:send", "linkedin:accounts:write", "admin:write"]}' \
> admin-device.json
curl -s https://app.makesmores.com/api/v1/integrations/device/code \
-H "Content-Type: application/json" \
-d '{"client_label": "linkedin-to-email-mailbox", "scopes": ["linkedin:activity:read", "linkedin:inbox:read", "linkedin:outreach:read", "linkedin:automation:write", "linkedin:outreach:send", "linkedin:outreach:cancel", "gmail:read", "gmail:write", "gmail:send"]}' \
> owner-device.json
jq '{user_code, verification_uri}' admin-device.json owner-device.json

The admin opens the first verification_uri, signs in, picks the workspace, enters its user_code and approves. The mailbox owner does the same with the second. Each code expires after 10 minutes. Then collect both credentials:

for who in admin owner; do
if [ -f "$who.json" ] && jq -e '.access_token' "$who.json" > /dev/null; then continue; fi
jq '{device_code}' "$who-device.json" \
| curl -s https://app.makesmores.com/api/v1/integrations/device/token \
-H "Content-Type: application/json" \
-d @- \
> "$who.next.json"
if jq -e '.access_token' "$who.next.json" > /dev/null; then mv "$who.next.json" "$who.json"; fi
done
jq -e '.access_token' admin.json > /dev/null
jq -e '.access_token' owner.json > /dev/null
jq '{expires_at, scopes: .grant.scopes}' admin.json owner.json

Until a person approves, their answer is 400 authorization_pending, so run the block again after interval seconds. A device code gives its credential once, so the block skips a sign-in it already collected. Asking again answers 400 invalid_grant. admin.json and owner.json hold live credentials: keep them out of version control.

An access token lasts 15 minutes, and this flow runs over days, so refresh both before expires_at. Each refresh token works once:

for who in admin owner; do
jq '{refresh_token}' "$who.json" \
| curl -s https://app.makesmores.com/api/v1/integrations/token/refresh \
-H "Content-Type: application/json" \
-d @- \
> "$who.next.json"
jq -e '.access_token' "$who.next.json" > /dev/null
mv "$who.next.json" "$who.json"
done

A sign-in lasts 90 days. What it approved lapses when it's revoked or runs out, or when its person is no longer an owner or admin, and nothing it approved sends until someone approves it again.

StepSign-inApprovesSavesUsed by
1AdminNothingpreview.json2
2AdminNothing. It sends and queues nothinglist.json, import-key.txt3, 13
3AdminNothingrows.jsonYou
4AdminNothing, unless you sign an account inaccount.txt5 to 15
5AdminS'more's checks of the account on LinkedInmonitoring.json13
6AdminNothingtemplates.json7, 11, 13
7AdminNothingdefaults.json8
8AdminThe automated first message, follow-up and email requestapply.jsonYou
9Mailbox ownerGoogle's consent to send from the mailboxmailbox.json10, 11
10Mailbox ownerThis mailbox may email for this LinkedIn accountpairing.json11
11Mailbox ownerEmail in ask modeemail.jsonWhen a person replies
12AdminNothingcampaign.json13 to 15
13AdminThe invitations, which S'more queues on LinkedInappend.json, append-key.txt, track-until.txt13, 14
14AdminNothingNothingYou
15AdminNothingautomation.jsonWhen a person replies

1. Preview the list​

With the admin sign-in. A preview writes nothing.

jq -n --rawfile csv people.csv '{mode: "linkedin_profiles", csv: $csv, file_name: "people.csv"}' \
| curl -s "https://app.makesmores.com/api/v1/outbound/lists?action=preview" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
| tee preview.json \
| jq '{rows_total, counts, mapping_error, columns: [.columns[] | {header, field}], issues}'
jq -e '.rows_total > 0 and .mapping_error == ""' preview.json > /dev/null

columns says which field each header fills, and step 2 sends that as the mapping. When the mapping can't be used, the preview still answers 200, with the reason in mapping_error, such as missing_profile, and the block fails. issues names the rows that wouldn't join the list, by their number in the file. The file can be up to 10 MiB and 20,000 rows, and previews count toward a limit on Rate limits.

2. Import the list​

With the admin sign-in. An import sends nothing and queues nothing.

[ -f import-key.txt ] || echo "people-$(date +%s)" > import-key.txt
jq -n --rawfile csv people.csv --slurpfile preview preview.json --arg key "$(cat import-key.txt)" \
'{mode: "linkedin_profiles", csv: $csv, file_name: "people.csv", name: "Conference leads",
mapping: ([$preview[0].columns[] | {(.header): .field}] | add), idempotency_key: $key}' \
| curl -s "https://app.makesmores.com/api/v1/outbound/lists?action=import" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
| tee list.json \
| jq '.list | {id, members, counts}'
jq -e '.list.id' list.json > /dev/null

list.id is the list that steps 3 and 13 read. The key in import-key.txt stays until you delete the file, so running this step again answers the same list and imports nothing new. The same key with a changed file answers 409 idempotency_conflict. A list's rows don't change after the import.

3. Read the list's rows​

With the admin sign-in.

curl -sG https://app.makesmores.com/api/v1/outbound/lists \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d id="$(jq -r .list.id list.json)" \
-d view=people \
-d limit=100 \
| tee rows.json \
| jq '{rows: [.items[] | {row_id, contact_id, outcome, reason, linkedin_url, linkedin_eligible, has_email}], next_cursor}'

A member of the list has a row_id and a contact_id. A row that didn't join it carries its outcome and reason. linkedin_eligible says whether the profile can be used for LinkedIn outreach. Send next_cursor back as cursor for the next page, until there's none.

has_email only says the contact has an address. It isn't permission to send, and S'more never emails an address from a list. Step 13 invites every member, so this read is for you to check the list first.

4. Pick the LinkedIn account​

With the admin sign-in.

curl -s https://app.makesmores.com/api/v1/linkedin/accounts \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
| tee accounts.json \
| jq '{connect_url, connections: [.connections[] | {connection_id, member_urn, status, can_send, invitation_note_limit}]}'
jq -r 'first(.connections[] | select(.can_send) | .connection_id)' accounts.json > account.txt
test -s account.txt

account.txt holds the account's connection_id, which later steps send as account_id in queries and connection_id in bodies. invitation_note_limit is the longest invitation note the workspace can send, so the note in step 6 has to fit it.

With no account that can send, a person can connect one in Settings at connect_url. Over the API, put the sign-in in a file of its own, signin.json, with accountKey (your name for the account), username and password, and keep that file out of version control:

curl -s https://app.makesmores.com/api/v1/linkedin/accounts \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @signin.json \
| tee signin-answer.json \
| jq '{id, state, message, actions, challenge}'

A sign-in is work S'more does on LinkedIn. It answers 202 while it runs or waits for a person, and no test yet covers which mix of password and cookies LinkedIn accepts. When state asks for a code, put the code LinkedIn sent in CODE and send it:

CODE=123456
ID=$(jq -r .id signin-answer.json)
curl -sG https://app.makesmores.com/api/v1/linkedin/accounts \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d id="$ID" \
> signin-now.json
jq -n --arg code "$CODE" '{verificationCode: $code}' \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/accounts?id=$ID&action=continue&challenge_id=$(jq -r .challenge.id signin-now.json)" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
| jq '{state, message, actions}'

If either call times out or answers a 5xx, read the account with GET /linkedin/accounts?id= before you send anything again, because the code may already have reached LinkedIn. Once the account is connected, run the first block of this step again.

5. Turn on monitoring​

With the admin sign-in. This saves the account's working hours and limits, and schedules S'more's checks of the account on LinkedIn.

ACCOUNT=$(cat account.txt)
curl -sG https://app.makesmores.com/api/v1/linkedin/settings \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d account_id="$ACCOUNT" \
-d section=monitoring \
> monitoring-now.json
jq '{expected_revision: (.revision // 0), enabled: true, time_zone: "America/New_York",
windows: [range(1; 6) | {day: ., start: "09:00", end: "17:00"}],
daily_limit: 100, cycle_limit: 20}' monitoring-now.json \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/settings?account_id=$ACCOUNT&section=monitoring" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
| tee monitoring.json \
| jq '{revision, enabled, tracking_state, initial_observation_pending, next_check_at}'
jq -e '.revision > 0' monitoring.json > /dev/null

An account with no monitoring answers the read with 404 not_found, so the body starts at revision 0. The PATCH replaces the hours and limits it takes, so send every one the account should have. daily_invitation_limit and auto_enroll keep their saved values when you leave them out. Leave out interval_minutes: S'more accepts it, but it doesn't change how often S'more checks.

monitoring.json holds the revision that step 13 sends as policy_revision. initial_observation_pending is true until S'more's first check of the account finishes. The first save also sets up the account campaign that step 12 reads, so if the answer is lost, run the block again: it reads the monitoring first and sends the revision it finds.

6. Save the templates​

With the admin sign-in. A save adds a revision and approves nothing.

TOKEN=$(jq -r .access_token admin.json)
ROUTE="https://app.makesmores.com/api/v1/linkedin/settings?level=workspace&section=templates"
curl -s "$ROUTE" -H "Authorization: Bearer $TOKEN" > templates-now.json
save() {
jq --arg name "$1" --argjson copy "$2" \
'$copy + {expected_revision: ([.items[] | select(.name == $name) | .revision] | first // 0)}' templates-now.json \
| curl -s -X PATCH "$ROUTE&name=$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- \
> template-answer.json
jq -e 'select(.revision) | {name, revision}' template-answer.json || { cat template-answer.json; return 1; }
}
save invitation '{"text": "Hi, I saw your talk at the conference and would like to connect."}'
save after_accept '{"text": "Thanks for connecting. What are you working on this quarter?"}'
save follow_up '{"text": "Following up on my note. Happy to share what we learned at the conference."}'
save request_email '{"text": "Glad you replied. What is the best email address for the details?"}'
save email '{"subject": "Great to connect, {first_name}", "body": "Hi {first_name},\n\nThanks for sharing your email. Here are the details I mentioned.\n\n{signature}", "signature": "Alex Kim"}'
curl -s "$ROUTE" -H "Authorization: Bearer $TOKEN" \
| tee templates.json \
| jq '[.items[] | {name, revision}]'

S'more sends LinkedIn text exactly as saved, so the four LinkedIn slots take no merge fields. The email takes {first_name} in the subject or a greeting line, and {signature} and {calendar_link} in the body, as Save the email template says. A save changes nothing already waiting to send. templates.json holds each slot's current revision, which steps 7, 11 and 13 name.

7. Save the automation defaults​

With the admin sign-in. This approves nothing.

TOKEN=$(jq -r .access_token admin.json)
ROUTE="https://app.makesmores.com/api/v1/linkedin/settings?level=workspace&section=automation"
curl -s "$ROUTE" -H "Authorization: Bearer $TOKEN" > defaults-now.json
jq --slurpfile t templates.json '
def rev($name): [$t[0].items[] | select(.name == $name) | .revision] | first;
{expected_revision: .revision, after_accept_delay_seconds: 3600, follow_up_delay_seconds: 259200,
steps: ["after_accept", "follow_up", "request_email"],
templates: {after_accept: rev("after_accept"), follow_up: rev("follow_up"), request_email: rev("request_email")}}' defaults-now.json \
| curl -s -X PATCH "$ROUTE" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- \
| tee defaults.json \
| jq '{revision, steps, templates, behind}'
jq -e '.revision > 0' defaults.json > /dev/null

The PATCH replaces the whole record. The first message waits after_accept_delay_seconds after someone accepts, and the follow-up waits follow_up_delay_seconds, at least 3,600, after the first message went. While email requests are closed, request_email in steps answers 400 invalid_request with field steps. Leave it out of steps and templates then, and S'more won't ask anyone for an address.

8. Activate the account​

With the admin sign-in. This is the approval: S'more may send the first message, the follow-up and the email request saved in step 6 to people who accept after the activation's cutoff_at. The approval is bound to the admin sign-in and lapses with it.

TOKEN=$(jq -r .access_token admin.json)
jq -n --slurpfile d defaults.json --arg account "$(cat account.txt)" \
'{defaults_revision: $d[0].revision, account_ids: [$account]}' \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/settings?level=workspace&section=automation&action=apply" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- \
| tee apply.json \
| jq '.results[0] | {result, reasons, revision, email}'
curl -sG https://app.makesmores.com/api/v1/linkedin/settings \
-H "Authorization: Bearer $TOKEN" \
-d account_id="$(cat account.txt)" \
-d section=automation \
| jq '{status, cutoff_at, readiness, expires_at: .approval.expires_at}'

result is activated the first time, with a new cutoff_at. needs_monitoring_policy with reasons means step 5 hasn't taken yet. Applying again with the same sign-in is safe and answers unchanged. While email requests are closed and steps has request_email, apply answers 409 automation_not_ready with reasons ["email_request_unavailable"] and changes nothing.

Activate before step 13. S'more sends automated messages only to people who accept after cutoff_at, and skips anyone who accepted earlier. readiness.reasons says why nothing runs yet, and lists automation_unavailable until S'more's first check of the account finishes. No test yet covers that change on a newly connected account.

expires_at is when the admin sign-in runs out. Before then, sign in again and apply with the new credential, which answers updated and keeps cutoff_at.

9. Connect the mailbox​

With the mailbox owner's sign-in. If they already have a connected mailbox, skip to the second block.

TOKEN=$(jq -r .access_token owner.json)
curl -s https://app.makesmores.com/api/v1/gmail/accounts \
-H "Authorization: Bearer $TOKEN" \
| jq -e '{available, can_connect} | select(.available == "connect" and .can_connect)'
curl -s -X POST https://app.makesmores.com/api/v1/gmail/accounts \
-H "Authorization: Bearer $TOKEN" \
| jq '{authorization_url, expires_at}'

The mailbox owner opens authorization_url in a browser where they're signed in to S'more, and gives consent at Google before expires_at, 10 minutes later. That consent lets S'more send from their address. Nothing is connected until they do it. Then save the mailbox:

curl -s https://app.makesmores.com/api/v1/gmail/accounts \
-H "Authorization: Bearer $(jq -r .access_token owner.json)" \
> mailbox-answer.json
jq -e 'first(.mailboxes[] | select(.is_mine)) | {id, email, status, revision}' mailbox-answer.json > mailbox.json \
|| { cat mailbox-answer.json; exit 1; }
cat mailbox.json

status should be connected. id and revision feed step 10.

10. Pair the mailbox​

With the mailbox owner's sign-in. This lets the mailbox send this LinkedIn account's email. It sends nothing.

ACCOUNT=$(cat account.txt)
jq '{mailbox_id: .id, expected_revision: .revision}' mailbox.json \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/settings?account_id=$ACCOUNT&section=email&action=request_pairing" \
-H "Authorization: Bearer $(jq -r .access_token owner.json)" \
-H "Content-Type: application/json" \
-d @- \
| tee pairing.json \
| jq --slurpfile m mailbox.json '{confirmed: (.pairing.confirmed.mailbox_id == $m[0].id), requested: .pairing.requested}'

When the mailbox owner is the workspace owner, confirmed is true at once. Otherwise the workspace owner confirms it with their own credential holding gmail:read and gmail:write. They read GET /linkedin/settings?account_id=&section=email, then send PATCH ...&action=confirm_pairing with the same mailbox_id and, as expected_revision, the revision shown for that mailbox in pairing.requested:

{ "mailbox_id": "44444444-4444-4444-8444-444444444444", "expected_revision": 3 }

An account has at most one confirmed mailbox, so a second confirmation answers 409 pairing_taken.

11. Approve email in ask mode​

With the mailbox owner's sign-in. From armed_at on, S'more reads the replies this LinkedIn account receives. When a person types an email address, it writes the approved template to that address and holds the email until the mailbox owner sends it. The approval is bound to the mailbox owner's sign-in and lapses with it.

ACCOUNT=$(cat account.txt)
TOKEN=$(jq -r .access_token owner.json)
curl -sG https://app.makesmores.com/api/v1/linkedin/settings \
-H "Authorization: Bearer $TOKEN" \
-d account_id="$ACCOUNT" \
-d section=email \
> email-now.json
jq -n --slurpfile e email-now.json --slurpfile m mailbox.json --slurpfile t templates.json '
{mailbox_id: $m[0].id, mode: "ask",
template_revision: ([$t[0].items[] | select(.name == "email") | .revision] | first),
expected_revision: ($e[0].policy.policy.revision // 0)}' \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/settings?account_id=$ACCOUNT&section=email&action=approve" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- \
| tee email.json \
| jq '{mode: .policy.policy.mode, revision: .policy.policy.revision, armed_at: .policy.policy.armed_at, binding, rebound, kept}'
jq -e '.policy.policy.mode == "ask"' email.json > /dev/null

expected_revision is 0 the first time and the policy's revision after that. Keep gmail:read on this sign-in. Without it, removing a pairing or turning email off answers the view a member gets, with no pairings and no template copy, and approving gives the same answer in code, though no test covers that yet.

Only the mailbox's owner approves. Another admin gets 403 mailbox_owner_required, and a credential without gmail:send gets 403 insufficient_scope. A reply that arrived before armed_at is never emailed, so approve before step 13.

This guide doesn't use auto mode. In auto mode an email can be scheduled to send on its own, and an opt-out recorded in LinkedIn automation isn't checked before that, so a person who opted out on LinkedIn can still be emailed. Approve email describes both modes.

12. Find the account campaign​

With the admin sign-in.

curl -sG https://app.makesmores.com/api/v1/linkedin/campaigns \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d account_id="$(cat account.txt)" \
> campaign-answer.json
jq -e 'first(.items[]) | {id, revision, status}' campaign-answer.json > campaign.json \
|| { cat campaign-answer.json; exit 1; }
cat campaign.json

A verified LinkedIn account has at most one account campaign, set up when its monitoring was first saved in step 5. Don't create one: POST /linkedin/campaigns with no query only enrolls an invitation batch that already exists. campaign.json holds the id that steps 13 to 15 take and the revision that step 13 sends first.

13. Invite the list​

With the admin sign-in. This approves the invitations: S'more queues them on LinkedIn, up to 100 a call, and follows each person until track_until. Run the block once for each chunk, until status is no longer open.

CAMPAIGN=$(jq -r .id campaign.json)
LIST=$(jq -r .list.id list.json)
[ -f append-key.txt ] || echo "list-$(date +%s)" > append-key.txt
[ -f track-until.txt ] || jq -nr 'now + 60 * 86400 | floor | todate' > track-until.txt
if [ -f append.json ]; then
jq '{chunk_index: .next_chunk_index, expected_revision: .chunk.revision}' append.json > next.json
else
jq '{chunk_index: 0, expected_revision: .revision}' campaign.json > next.json
fi
jq -n --slurpfile n next.json --slurpfile m monitoring.json --slurpfile t templates.json \
--arg account "$(cat account.txt)" --arg key "$(cat append-key.txt)" --arg until "$(cat track-until.txt)" '
$n[0] + {connection_id: $account, idempotency_key: $key, policy_revision: $m[0].revision, track_until: $until,
invitation_template_revision: ([$t[0].items[] | select(.name == "invitation") | .revision] | first)}' \
| curl -s "https://app.makesmores.com/api/v1/linkedin/campaigns?id=$CAMPAIGN&list_id=$LIST" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
> append-answer.json
if jq -e '.id' append-answer.json > /dev/null; then mv append-answer.json append.json; else cat append-answer.json; exit 1; fi
jq '{status, chunks_done, chunks_total, next_chunk_index, failed_chunk_index, failure_reason, totals}' append.json

With invitation_template_revision and no rows, every member of the list gets the invitation template as their note, and nobody has a first message of their own. After someone accepts, the first message S'more sends them is the after_accept step that step 8 approved.

status is open while chunks remain, complete when every chunk is done, and failed when a chunk can't go, with failed_chunk_index and failure_reason. There's no way to cancel a run. To leave it where it is, stop running the block. Each call can take up to 3 minutes.

After a lost answer, a 5xx or a timeout, run the block again unchanged. It sends the same body, because append.json still holds the last answer that arrived, and a chunk whose invitations were queued isn't queued a second time. A resend while the first call still holds the chunk answers 409 activity_conflict, so wait and run it again. LinkedIn list appends covers the other answers.

On a newly connected account this is the campaign's first enrollment, and no test yet covers appending a list to an account campaign that has no enrollment.

14. Check the run​

With the admin sign-in.

curl -sG https://app.makesmores.com/api/v1/linkedin/campaigns \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d id="$(jq -r .id campaign.json)" \
-d append_id="$(jq -r .id append.json)" \
| jq '{status, chunks_done, next_chunk_index, failed_chunk_index, failure_reason, totals,
chunks: [.chunks[] | {index, status, batch_id: .result.batch_id}], rows: [.rows.items[] | {row_id, progress, reason}]}'

Reading a run never moves it on. Each chunk's batch_id is its invitation batch, which GET /linkedin/outreach?id= reads row by row. Each row's progress is pending, added, already_present, conflicting, blocked or rejected, and reason says why a row didn't go. The progress query narrows the rows to one of them, and rows.next_cursor goes back as cursor for the next page.

15. Follow each person​

With the admin sign-in.

curl -sG https://app.makesmores.com/api/v1/linkedin/recipients \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-d campaign_id="$(jq -r .id campaign.json)" \
-d view=automation \
-d limit=100 \
| tee automation.json \
| jq '{people: [.items[] | {id, recipient_urn, status, next_check_at, revision: .automation.revision,
steps: [.steps[] | {step, state, hold_reason, stop_reason, due_at, next_eligible_at, outreach_batch_id, outreach_status}]}],
next_cursor}'

There's one item for each person in each enrollment, with their automated steps. automation.revision is the expected_revision of a change to that person. next_cursor is always in the answer and is "" on the last page. A step's outreach_batch_id is the LinkedIn batch that sends it, which GET /linkedin/outreach?id= reads.

What happens next depends on what each person does.

When a person replies​

You don't call anything: S'more's next check sees the reply. A first message or follow-up that hasn't started is cancelled with stop_reason inbound_received, and a reply that arrived before the activation's cut-off plans nothing.

They reply with no address​

S'more plans one email request, request_email, with due_basis reply_observed, and sends it under the approval from step 8. If the person writes again before the request goes, S'more asks once more about the newer message, and a second attempt that fails is final. Email requests can be closed separately from the rest of LinkedIn automation, as Gates shows.

They type an email address​

S'more writes the approved email to the address they typed, and skips the request as address_shared. Until it has read the reply for an address, the request waits as email_scan_pending. While email isn't approved, the request is held as address_shared_email_off, and an address S'more can't use holds it as address_not_eligible.

In ask mode the email's status is held with hold_reason ask_first. A reply with more than one address, a shared inbox address or words asking not to be contacted holds it as several_addresses, shared_inbox or opt_out_words instead. A reply that arrived too late holds it as late, and someone writing to the person on LinkedIn after their reply holds it as manual_linkedin_reply. Each waits for the mailbox owner.

The mailbox owner reads the held emails:

curl -sG https://app.makesmores.com/api/v1/linkedin/emails \
-H "Authorization: Bearer $(jq -r .access_token owner.json)" \
-d account_id="$(cat account.txt)" \
-d status=held \
| tee emails.json \
| jq '{counts, emails: [.items[] | {id, revision, status, hold_reason, to_email, other_addresses, subject, body, can_send}]}'

The emails need gmail:read and linkedin:inbox:read. A credential without linkedin:inbox:read gets counts with items empty. can_send is true for the mailbox's owner, who alone sends.

Before you send, find the person in automation.json from step 15 by the email's recipient_urn, and check automation.opted_out_at and automation.paused_at. An email isn't held because of either, so dismiss it yourself if they're set.

To send one, the mailbox owner reads its to_email, subject and body, then sends it with its id and revision. This approves this one email to this one address:

EMAIL_ID=abcdabcd-abcd-4bcd-8bcd-abcdabcdabcd
jq --arg id "$EMAIL_ID" 'first(.items[] | select(.id == $id)) | {action: "send", expected_revision: .revision}' emails.json \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/emails?id=$EMAIL_ID" \
-H "Authorization: Bearer $(jq -r .access_token owner.json)" \
-H "Content-Type: application/json" \
-d @- \
| jq '{id, status, send_at, to_email}'

The email is scheduled to go a few minutes later, within the account's hours. A reply the person writes after you send doesn't stop it, though no test covers that yet. While its status is scheduled, action=cancel still stops it. To send to one of its other_addresses instead, add to_email with that address. cancel and dismiss end an email without sending it, and edit changes its subject and body and keeps it held, as Send, edit or send again describes.

They send only an attachment​

S'more can't read it, so the request is held as attachment_only for an owner or admin to review. Either sign-in can change it, because both hold linkedin:automation:write, linkedin:inbox:read and linkedin:outreach:send. Take the person's id and automation.revision from step 15, and the urn of their newest message from GET /linkedin/conversations?view=messages&conversation_urn=&account_id=, with the thread's conversation_urn.

To ask them for an address, send change request_email. This approves asking for an address, which S'more sends on LinkedIn later. Their first message or follow-up that hasn't started ends with stop_reason inbound_received, though no test covers that part yet:

PERSON=12121212-1212-4212-8212-121212121212
MESSAGE=urn:li:msg_message:jo-3
jq --arg id "$PERSON" --arg message "$MESSAGE" \
'first(.items[] | select(.id == $id)) | {change: "request_email", message_urn: $message, expected_revision: .automation.revision}' automation.json \
| curl -s -X PATCH "https://app.makesmores.com/api/v1/linkedin/recipients?id=$PERSON&view=automation" \
-H "Authorization: Bearer $(jq -r .access_token admin.json)" \
-H "Content-Type: application/json" \
-d @- \
| jq '{revision: .automation.revision, steps: [.steps[] | {step, state, hold_reason}]}'

To decline, send change continue with the same message_urn. The request is then skipped as superseded and isn't asked again for that message, though no test covers that case yet. continue doesn't approve an email. If the held message isn't one the person wrote, such as a system message, the same call lets a first message or follow-up held for it wait again, and that step can then send.

A message S'more can't read, such as a system message or one with no text, holds the person's steps as unsupported_message or empty_message instead. Review it with change continue and its message_urn. request_email takes only an attachment_only hold and answers 409 activity_conflict on any other, though no test covers it on these two holds yet.

When a person doesn't answer​

  • If they never accept the invitation, S'more sends them no automated message. Following them ends at track_until, and their enrollment then reads expired.
  • If they accept, S'more sends the after_accept message after_accept_delay_seconds later, unless they reply first. A person with no first message, because the after_accept step isn't on and none was approved for them, gets nothing, so no follow-up is planned for them either, though no test covers a person with no first message yet.
  • If the first message went at or after the cut-off and nobody has written in the conversation since the invitation, S'more plans one follow_up, due follow_up_delay_seconds after the first message, with due_basis main_sent, and then nothing more. That needs the activation still active and the follow-up step and template bound. A follow-up that fails ends as not_sent and isn't tried again, and one that answers send_unknown stays uncertain. A follow-up another enrollment already sent the same person skips this one as duplicate_enrollment, and one still unconfirmed holds it as prior_send_unknown.
  • If they reply after the follow-up, what happens is as in When a person replies, though no test covers that order yet.

Rules this flow follows​

Imported addresses​

S'more emails only an address the person typed in their reply, as the email's to_email. Sending accepts that address or one of the other_addresses from the same reply. An address on the imported list or on a contact is never a send target, and importing a list queues nothing. An imported email address isn't permission to send.

Opt-out​

S'more records an opt-out in two ways. Its check reads words asking not to be contacted in the LinkedIn reply of a person who still has an automated step to come, while the account's automation is active or paused, though no test covers a paused account yet. Or an owner or admin sends change opt_out on that person with a credential holding linkedin:outreach:cancel, such as the mailbox owner's sign-in. Either way, S'more cancels their automated LinkedIn steps that haven't started, with stop_reason opt_out, and plans no more for them in that enrollment. It's recorded for that person in that enrollment, as automation.opted_out_at. Don't rely on it to stop a message you submit yourself on /linkedin/outreach or a typed reply.

A LinkedIn opt-out isn't checked before an email is scheduled or sent, and a held email doesn't show it. In ask mode the mailbox owner checks the person's automation.opted_out_at before sending, and can dismiss the email. Pausing a person with change pause doesn't hold their email either. A reply that has both opt-out words and an address is held as opt_out_words either way.

Someone takes over by hand​

When someone writes to the person on LinkedIn by hand, or sends a typed reply, S'more doesn't send its own automated messages to them that haven't gone yet. An email waiting for them is held as manual_linkedin_reply, and the mailbox owner's send releases it once.

Working hours​

An automated LinkedIn step goes at the first check at or after its next_eligible_at, and checks come 45 to 90 minutes apart within the monitoring hours from step 5. Automated steps need monitoring saved and turned on. Typed replies go at any hour. Email goes only within the same hours and never without saved hours, and while an email waits for them its wait_reason can be empty.

How current the answers are​

S'more's view of LinkedIn is only as current as its last check:

  • mode=activity and mode=cohort on Stats: as_of, last_success_at, connections_checked_at, inbox_checked_at and workflow.
  • Monitoring: tracking_state, inbox_coverage, acceptance_coverage, next_check_at and initial_observation_pending.
  • A thread's messages on Conversations: inbox_complete and checked_at.
  • view=automation: next_check_at and each step's next_eligible_at.
  • mode=automation stats carry no freshness field, so read the monitoring with them.

Partial progress​

Many calls here can do part of their work. Read what each one says it did:

  • the preview's counts, and the import's counts and members
  • the list append run's totals and chunks, and each chunk's added_row_ids, already_present_row_ids, conflicting_row_ids and blocked_row_ids
  • apply's results, one for each account
  • an outreach batch's progress, next_send_at and next_send_reason
  • an enrollment's skipped_row_ids
  • each email's status, and the emails' counts

If something goes wrong​

Read the state before you act again. Recover from a failed or uncertain write has a row for each of these:

  • A 409 revision_conflict or 409 activity_conflict means something changed since you read it. Read it again and send the change with the new revision, as in Any LinkedIn route.
  • A 503 request_timeout doesn't mean nothing happened. Read the state first, as in Any LinkedIn route.
  • A list append whose answer was lost, or whose chunk failed or conflicted, is in LinkedIn list appends.
  • A LinkedIn message whose status is send_unknown is never sent again on its own. Don't submit it again, as in LinkedIn messages.
  • An email whose status is send_unknown is checked in the mailbox's Sent folder, then marked with action=mark, as in LinkedIn email.
  • A lapsed approval is approved again, which starts a new cut-off, as in LinkedIn automation and LinkedIn email.
  • A closed feature answers as Gates says, and retrying won't help until it opens.

API conventions covers what every route shares, including Retries and Timeouts. S'more doesn't promise exactly-once delivery.