1. Preview the plan
POST /api/mass-actions/preview validates the whole plan without persisting, charging or sending anything, and reports every finding at once.
POST /orchestration/v4/api/mass-actions/preview
- Step vocabulary. Only step-eligible verbs can appear in
plan.steps[].tool. Anything else fails withvalidation_failedand afield_errorsentry naming the authorable set, so the plan is repairable in one pass. - One sender per run. A step that acts as one of the team’s accounts names it in its args:
linkedin_account_sidfor a connect, message, follow, react or comment step,email_account_sidfor an email step. A target’s payload carries the target (ln_id, the profile URN) and its per-send extras (note,client_reference), never the sender: a plan that leaves the sender out ofargsfails preview withsender_required. - One text per run. A message or a comment step carries the text every target gets in
args.text; a plan without it fails preview withtext_requiredinstead of dying on its first item. - The sender can send right now. Before a run is created, the preview asks the LinkedIn service about every sender the plan names: an account still in its first sync (
initial_sync_pending), lent to another team (shared_out), parked by the subscription (subscription_required), signed out of LinkedIn (signed_out), restricted by LinkedIn (restricted), parked for an error investigation (error_investigation), or not connected in this workspace (not_found) fails preview withsender_not_readyonplan.steps.N.args.linkedin_account_sid, and the message says what to do (wait forlinkedin-accounts.initial-sync-done, sign in again, recall the share). The create asks again, so a sender that changed state between preview and commit is refused at the commit. Until 2026-10-23 such a run was created and its first item failed on the canary; a run created minutes after connecting its account was the usual case. - Scope shape.
objects(existing rows by sid),targets(per-item payload identities),generate(N slots, step 1 must create the object), ornone(a standing run an auto-scrape feeds later). All bounded at 100 items. - A target is one person. Name them by
ln_id(the profile URN,ACoAA...) orsn_id(the Sales Navigator id,ACwAA...);profile_idtakes either. Or name them by their vanity slug innickname(alinkedin.com/in/...link works too): the send step resolves it to the URN when it runs, for free when the team already knows the person (a connection, an invitation, a follower, a lookup of the last day) or the corpus holds their profile from the last 30 days, otherwise with a lite-profile read on the sending account, which spends that account’senrichmentbudget and waits for the budget to reset when it is spent.ln_member_idis the decoded member number (digits only), and no send step can dispatch on it alone. A slug inln_member_id, an id that does not decode, or a Recruiter id where a member verb wants a profile URN fails the preview on that target (scope.targets.3.ln_member_id,ln_member_id_not_numeric), and the same person named twice (their URN and their Sales Navigator id, or their slug and its link) counts once. - Every target is reachable. A connect, message or follow step needs a URN or a slug in each target; a message also takes an existing thread (
linkedin_conversation_sid). A target with a member number alone, a company id or no person at all fails the preview withtarget_identity_requiredonscope.targets.N, a company page or a Recruiter id innicknamefails it withpublic_identifier_not_resolvable, and the same holds for a lead appended to a running run (identities.N). - Schedule mandate. A plan with a send-class step must carry a
schedule; the per-gap interval is randomized between your min and max, because a fixed cadence is itself a detectable pattern. Omitscheduleonly for plans that can drain immediately.
action envelope returns a preview block (items_count, dangerous_steps, eta, warnings) plus commit_token and expires_at.
2. Commit it
POST /api/mass-actions takes the exact same inputs plus the commit_token. The token is an HMAC over those inputs and the caller: edit anything, and the commit fails with validation_failed; wait past 15 minutes, and it expires. Re-preview in either case.
Two behaviors to design around:
- The run is always asynchronous, even for one item. The returned
sidis your monitoring handle. - A still-valid token can be replayed, and a replay creates a second identical run. Discard the token the moment the commit succeeds.
canary_mode: "first_item", only item 1 dispatches until it succeeds. A canary failure pauses the whole run with paused_reason: canary_failed, so a broken template costs you one send, not a hundred. The run then waits for your call, one of three:
- Fix what the item needed and retry it by its
sid(POST /api/mass-action-items/retry), thenPOST /api/mass-actions/{sid}/resume: the retried canary goes alone, and its success opens the gate for the rest. - Accept the failure:
POST /api/mass-actions/{sid}/releaseopens the gate by hand, then/resume. - Delete the run and start over with a corrected plan (plans are immutable).
mass-actions.settled fires.
3. Monitor to settlement
Runs are visible in the app whoever authored them: yourself, an API key, an agent over MCP, or the platform. Mass Actions lists them with their pacing, live counts and state.
The Mass Actions list: one row per run, with items done against total, the randomized pacing window, the derived state and who authored it.

A run: the read-only step plan on top, then per-item rows with status, error and attempt count.
GET /api/mass-actions/{sid}?include[]=metricsreturns the run with per-status item counts.POST /api/mass-action-items/searchwithfilter: { "mass_action_sid": { "eq": "..." } }lists the individual items and their step logs.- Or skip polling: subscribe a webhook to the
mass-actions.settledandmass-actions.pausedevents. POST /api/mass-actions/searchlists the runs;filter.title(eq,in) finds one by the label you gave it, andfilter.qsearches the titles.
paused_reason: limit_reached and hold_till at the moment the budget comes back: the next midnight in the account’s own timezone (its sync_config.timezone, UTC when it keeps none). The scheduler resumes the run then and re-plans the remaining items onto a fresh pacing chain from that moment, so they neither hammer the limit nor all go out at the reset in one burst. mass-actions.paused and mass-actions.resumed fire on the way. Resuming by hand before hold_till only parks the run again at the first item the budget refuses; pausing by hand replaces the hold and waits for your resume.
Control verbs while it runs: POST /api/mass-actions/{sid}/pause and /resume, /release to open a closed canary gate by hand, and /append-items to feed more leads into a run that is going. A run held for a spent limit still takes them; a run paused for any other reason, or deleted, answers 409 run_not_active with context.reason_detail saying which and context.detail what to do.
4. Retry failures
POST /api/mass-action-items/retry re-enters failed items at their current step. Completed steps are never re-executed, so there are no duplicate creates and no double sends. Target exactly one of a single item sid or a filter (pass mass_action_sid in the filter unless you mean every run). Only status: failed rows match, and a retry matching nothing returns retried_count: 0, not an error.
Two guards hold whatever you pass. An item is retried at most 10 times, and an item of a deleted run, or a cancelled one, is never retried. By sid these answer 422 retry_limit_exceeded and 409 cancelled_cannot_retry. A filter leaves such items alone and reports the first kind in left_at_retry_limit_count.
A send is not sent again on a guess. Each send of a run (a message, a connection request, a comment) goes out under a key of its own (Sending once explains the keys), and a send whose answer was lost waits with wait_reason: send_outcome_unknown while the run asks the owning service what came of it. It goes again only when the answer is not_sent. If nobody can tell within 24 hours, the item fails with send_unconfirmed: in its error_message, and a plain retry asks again for another day. An item_timeout: failure on a send step is asked about the same way. To send it anyway, look on LinkedIn first: the conversation, the person’s invitations, the post’s comments. If it is not there, retry that one item with your word:
POST /orchestration/v4/api/mass-action-items/retry
resend_unverified vouches for one send, so it takes a sid and never a filter (422 resend_unverified_needs_sid). A comment carries a key since 2026-10-05 like every other send. A send step that began before its key existed, such as a comment of a run that was already going, cannot be asked about: a plain retry leaves it failed with a note and counts it in left_unconfirmed_count, and only resend_unverified sends it again.
On a step that is not a send, an item_timeout: failure still means the outbound call may have landed: check the target before retrying a step that is not safe to repeat.