Skip to content

Outreach API playbook

This playbook covers searching a user's private Outreach network and managing its human/agent-driven campaign Memberships. LinkedIn export uploads are web-only; this API never calls LinkedIn and does not send messages or connection requests directly. It can create and manage work in the same Outreach action queue used by the browser extension.

The live schema at /api/v1/openapi.json and Swagger UI at /api/v1/docs are authoritative.

Authentication and access

Use the standard /api/v1/ 24-hour bearer token described in API.md. Browser session cookies and the static service token do not authenticate this surface.

An Outreach network is visible only to a global editor or a user with direct team-active edit access to that Holon. Inaccessible networks return 404, and their Memberships are removed from Person membership and responsible-worklist responses. Imported People themselves remain part of the shared METIS Person directory.

The examples assume:

export METIS_URL="https://your-metis.example"
export METIS_TOKEN="your-24-hour-token"
export NETWORK_ID="123"

Discover the network

curl -sS "$METIS_URL/api/v1/holons?class=outreach-network&limit=100" \
  -H "Authorization: Bearer $METIS_TOKEN"

The importing actor normally sees one Outreach network. Use the returned Holon id for subsequent calls.

Search imported connections

The importer represents a connection as a Membership using the Journey slug outreach-linkedin-network.

curl -sS \
  "$METIS_URL/api/v1/holons/$NETWORK_ID/memberships?journey=outreach-linkedin-network&q=climate&sort=name&limit=50&offset=0" \
  -H "Authorization: Bearer $METIS_TOKEN"

Supported filters are:

Parameter Meaning
q Case-insensitive Person name, description, contact channel, LinkedIn headline/about/location, or current title/company substring.
journey Exact Membership Journey slug.
step_slug Exact current step slug.
responsible_person_id Exact responsible Person ID.
follow_up overdue, today, future, or none.
sort name, -name, follow_up_after, or -follow_up_after.
limit Page size, from 1 to 200.
offset Zero-based page offset.

count is the number of items in the current page, not a total. Continue with offset + limit while has_more is true. Each item includes stable journey_slug and step_slug fields.

Find People for a campaign

Search the shared Person directory independently of network Memberships:

curl -sS "$METIS_URL/api/v1/people?q=Ada&limit=100&offset=0" \
  -H "Authorization: Bearer $METIS_TOKEN"

This permits adding People who were not in the LinkedIn export.

Load researched candidates into a list

The route for people you found outside METIS — a web or AI-assisted sweep that ends in a spreadsheet of names, organisations and profile URLs. It gives them the same home as people read off a LinkedIn search by the extension: an Outreach List, reviewed and triaged on its own screen before anyone is added to a campaign.

Access is Outreach app access plus edit rights on the list. That is deliberately not the global edit access POST /api/v1/people requires — see What this never does for the trade that makes it safe.

Create the list first. This is not idempotent, so find an existing one with GET /api/v1/outreach/lists before making a second:

curl -sS -X POST "$METIS_URL/api/v1/outreach/lists" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Wealth stewardship research — August",
    "description": "Desk research, 55 contacts with resolved LinkedIn profiles."
  }'

Then add up to 500 candidates per call:

curl -sS -X POST \
  "$METIS_URL/api/v1/outreach/lists/$LIST_ID/candidates:bulk-add" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "name": "Ada Lovelace",
        "linkedin": "https://www.linkedin.com/in/ada-lovelace/",
        "description": "Analytical Engine — London",
        "note": "Found via the Royal Society directory."
      },
      {"name": "Grace Hopper", "email": "grace@example.com"}
    ]
  }'

Every item gets its own outcome, so a spreadsheet with three bad rows still loads the other fifty-two:

Field Meaning
outcome created (now on the list), already_present (was already), or error
person_created True only when this call created the Person. The answer to "which of these are genuinely new to METIS"
person_id / membership_id What it resolved to
error.code invalid_linkedin, no_contact_channel, invalid_step, ambiguous_match, or internal_error

Replaying a batch is safe: the logical identity is (person, list, journey), so a repeat returns already_present and writes nothing.

That is why every item needs a linkedin or an email. A row carrying only a name cannot be matched — see below — so a second call could not recognise it and would add the same person again. Such a row comes back as no_contact_channel and nothing is written.

Candidates start on the list's first review step (captured, shown as Unreviewed) unless you send a step_slug. From there they are triaged and carried into a campaign on the list's own screen in the web app — this API loads the list, it does not promote from it.

What candidate intake never does

A candidate matches an existing Person by normalised LinkedIn URL or email, never by name: two people genuinely share a name, and a wrong merge is not undoable the way a duplicate is.

When it matches, nothing you send is written onto that Person. An email already on file cannot be replaced from your research, and a description you supply is used only for a Person this call creates. This is what makes the wider access safe: adding somebody to a review list is not an edit to the shared record. It is also the difference from POST /api/v1/people, which refuses a duplicate outright with 409 because it would be writing one.

When the two channels point at different People — or one matches several — the item comes back as ambiguous_match with error.person_ids. That is a question for a human in the web app, not a tie for the API to break.

An API-loaded list carries no LinkedIn search state, so its capture line reads Not started. Nothing is waiting to run; there was no search.

Bulk-add campaign Memberships

Add between 1 and 500 People to the seeded outreach-prospecting Journey:

curl -sS -X POST \
  "$METIS_URL/api/v1/holons/$NETWORK_ID/memberships:bulk-add" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "journey": "outreach-prospecting",
    "items": [
      {
        "person_id": 501,
        "step_slug": "candidate",
        "responsible_person_id": 42,
        "follow_up_after": "2026-08-01",
        "note": "Strong fit for the research cohort"
      },
      {
        "person_id": 502,
        "step_slug": "researching"
      }
    ]
  }'

The response reports created, already_present, and errors, plus one result per input item. Invalid Person IDs, steps, responsible People, or dates are item-level errors; other valid items still run.

Outreach uses (person, holon, journey) as the logical identity. A request made after the first one has completed returns already_present and does not mutate the existing step, responsible person, follow-up date, or notes. METIS does not currently provide a database-level uniqueness guarantee for simultaneous generic Membership writes, so clients should not issue overlapping bulk-add requests for the same Holon.

The infrastructure Journeys outreach-network-owner and outreach-linkedin-network reject generic bulk addition.

Review a campaign

curl -sS \
  "$METIS_URL/api/v1/holons/$NETWORK_ID/memberships?journey=outreach-prospecting&step_slug=researching&sort=follow_up_after&limit=100" \
  -H "Authorization: Bearer $METIS_TOKEN"

The seeded step slugs are candidate, researching, ready-to-connect, connection-requested, connected, paused, and do-not-contact.

Update one campaign Membership

A note is required on each state-changing update so the action has human-readable context. Fields omitted from the request remain unchanged; an explicit null clears follow_up_after or responsible_person_id.

curl -sS -X POST \
  "$METIS_URL/api/v1/memberships/987/update" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "note": "Research complete; ready for a human to connect",
    "step_slug": "ready-to-connect",
    "follow_up_after": "2026-08-05",
    "responsible_person_id": 42
  }'

Use advance_step: true instead of step_slug to move to the next active step. Do not send both controls in one request.

Read and enrich a LinkedIn profile

An external agent can attach an observed LinkedIn profile to any Person who has a Membership on the caller's Outreach network:

curl -sS -X POST \
  "$METIS_URL/api/v1/outreach/people/123/linkedin/update?network_id=$NETWORK_ID" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "email": "profile-email@example.com",
    "headline": "Building better coordination systems",
    "about": "Profile biography…",
    "location": "Lisbon, Portugal",
    "current_position": {
      "title": "Founder",
      "company": "Example Labs"
    },
    "education": [
      {"school": "Example University", "degree": "MSc"}
    ],
    "connection_count": 500,
    "connected_on": "2026-07-19",
    "observed_at": "2026-07-22T10:00:00Z"
  }'

The update accepts only the documented LinkedIn fields; it does not accept an arbitrary Person.infos object. Non-empty observations refresh their prior source values, while omitted or blank values leave prior observations alone. email is the source-observed LinkedIn email and does not replace the canonical Person contact email. connected_on updates the caller's LinkedIn Network Membership and is rejected when that Membership does not exist.

LinkedIn enrichment never changes the Person's canonical name, description, contact details, or photo. Responses return both description and display_description: the latter falls back to LinkedIn about, then headline, only when the canonical description is blank. Responses also return the LinkedIn network_membership_id and connected_on date when present.

Only contacts without a linked METIS login account can be enriched. The update route returns 403 when Person.user_id is set; the read route remains available and does not change the Person.

Read the current source observation without changing it:

curl -sS \
  "$METIS_URL/api/v1/outreach/people/123/linkedin?network_id=$NETWORK_ID" \
  -H "Authorization: Bearer $METIS_TOKEN"

Both routes require Outreach app access and standard edit access to the selected network. The network_id parameter may name the caller's own network or a network shared through an ordinary team-active Membership. It defaults to the caller's owned network for compatibility, or to their only accessible network when they do not own one. The routes return 404 unless the network is accessible and the Person has any Membership on it.

Create message actions

An action is a queued unit of Outreach work. For a LinkedIn message, use the campaign Membership as the target identity. METIS derives the target Person and network from it, then applies standard Holon edit permission to that network.

The queue is one ordered list per actor. Created actions join it at the end, in the order you send them, so a bulk push never displaces work already queued. batch_id is a free-text label for the group work arrived in — it is returned, filterable, and editable, and it does not affect the order anything runs in. Ordering is the operator's, changed from the queue screen in METIS.

curl -sS -X POST \
  "$METIS_URL/api/v1/outreach/actions:bulk-create" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "action_type": "send_message",
        "membership_id": 987,
        "message_body": "Hello Ada — I enjoyed your recent work on…",
        "scheduled_for": "2026-08-06T10:00:00Z",
        "batch_id": "climate-founders-2026-08"
      }
    ]
  }'

Requests accept 1–500 items and return created, already_present, and errors, plus an outcome for every item. A sequential retry reuses the same active logical action. METIS does not claim database-level uniqueness for overlapping requests.

request_connection, send_message, and check_connection_state require an outreach-prospecting Membership on any Outreach network the caller may edit. download_profile may instead use target_person_id.

publish_post is refused at intake: nothing in METIS can execute it, and a queue that accepted it would hold a row no engine would ever take. Items of that type come back with a no_engine outcome and a sentence saying so, rather than being queued and left pending. Every other declared action type maps to something that can run it — a build-time check enforces that there is no third answer.

Search and read actions

curl -sS \
  "$METIS_URL/api/v1/outreach/actions?action_type=send_message&status=pending&journey=outreach-prospecting&limit=50&offset=0" \
  -H "Authorization: Bearer $METIS_TOKEN"

The list supports q, status, action_type, membership_id, journey, step_slug, batch_id, scheduled_after, scheduled_before, sort, limit, and offset. q searches the target name and message draft. Page until has_more is false.

Read one action, including its full draft, with:

curl -sS "$METIS_URL/api/v1/outreach/actions/456" \
  -H "Authorization: Bearer $METIS_TOKEN"

Actions are actor-scoped. Another actor's action returns 404.

Edit an action

Pending, failed, blocked, and rate-limited actions allow draft, scheduling, and batch edits. Omitted fields remain unchanged; explicit null clears the schedule or batch.

curl -sS -X POST \
  "$METIS_URL/api/v1/outreach/actions/456/update" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "message_body": "Revised personal message",
    "scheduled_for": null
  }'

Transition an action

curl -sS -X POST \
  "$METIS_URL/api/v1/outreach/actions/456/transition" \
  -H "Authorization: Bearer $METIS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "status": "done",
    "result_summary": "Sent in LinkedIn"
  }'

Supported transitions follow the queue lifecycle:

  • pending → any other state. An action can conclude without ever having been reported as running;
  • runningpending (re-queue for a later retry) or any concluded state: done, partial, failed, blocked, cancelled, rate_limited;
  • partial, failed, or rate_limited can return to pending or be cancelled; blocked can do either and may also be completed;
  • done and cancelled are terminal.

partial means some of the work concluded and some could not — a profile read where only two of four sections could be reached. It is finished but resumable, so it is not terminal and does not block a later action for the same person.

Transitioning an action to the status it already has is a no-op and returns the action unchanged, so a result reported twice is applied once.

A send_message action needs a non-empty message body before it can become done. The transition endpoint records action state only: it does not change a Membership step or create follow-up actions automatically.

Client rules

  • Treat 400 and 422 as non-retryable input errors.
  • Refresh the token after 401; do not retry blindly after 403 or 404.
  • Page until has_more is false.
  • Use Journey and step slugs for workflow logic; display names are for humans.
  • Inspect every bulk item outcome even when the HTTP status is 200.
  • Treat action done as a reported outcome; METIS does not independently verify that LinkedIn accepted a message or connection request.
  • Do not infer LinkedIn state from a METIS step. Steps contain facts supplied by users or authorized external clients.