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 asrunning;running→pending(re-queue for a later retry) or any concluded state:done,partial,failed,blocked,cancelled,rate_limited;partial,failed, orrate_limitedcan return topendingor be cancelled;blockedcan do either and may also be completed;doneandcancelledare 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
400and422as non-retryable input errors. - Refresh the token after
401; do not retry blindly after403or404. - Page until
has_moreis 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
doneas 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.