# Relay API guide

Relay sends LinkedIn outreach from your senders' own accounts and keeps them safe while doing it:
connection requests, messages, InMails, profile views, follows, likes and endorsements, each under per-sender limits,
in working hours, with every outcome recorded as an event. Everything the web app does goes through this API, and the
same capabilities are exposed to AI agents as MCP tools at `/mcp`.

- Reference (every endpoint, generated from the code): [/docs](/docs) · machine-readable: [/v1/openapi.json](/v1/openapi.json)
- This guide as Markdown, for feeding to an AI: [/docs/guide.md](/docs/guide.md)

## Concepts

| Thing | What it is |
|---|---|
| **Workspace** | One client or team. Everything below belongs to exactly one workspace. An API key sees one workspace only. |
| **Sender** | A connected LinkedIn account. Has its own limits, working hours, time zone and warm-up. |
| **Lead** | A person. Stored once per workspace, matched on LinkedIn member id (`ACoA…`) *or* vanity (`/in/jane-doe`); two records found to be one person are merged. |
| **List** | A named group of leads (for imports and enrolment). |
| **Campaign** | A sequence + senders + leads + safety settings. Lifecycle: `draft → (approved) → active ⇄ paused → completed / archived`. |
| **Sequence** | A graph of steps (view profile, invite, message, delay, condition …) with branches. |
| **Action** | One thing done to one lead by one sender (an invite, a message …). Created and sent by the engine. |
| **Event** | Something that happened (`invite.accepted`, `reply.received` …). Append-only; feeds analytics, the event feed and webhooks. |

## Authentication

Send a workspace key on every request:

```
Authorization: Bearer rl_live_…
```

Keys are created by a person in the app (Settings → API keys) or with the server CLI; a key cannot create keys.
Each key has **scopes**:

- `read`: see everything in the workspace
- `write`: change leads, lists, campaigns, settings; write per-lead drafts; make a sender more careful (never less: see below)
- `send`: anything that makes LinkedIn actions happen: approve and start/resume campaigns, send inbox replies
- `approve`: approve or reject per-lead drafts. No key has it unless an admin ticks it when creating the key, and a key
  never approves a draft it wrote itself (see [Agent-written messages and approvals](#agent-written-messages-and-approvals))

**What a key can never do**, whatever its scopes: raise a sender's limits, shorten its gaps or switch its warm-up off;
loosen a freezing rule; lift a freeze; widen working hours; switch a campaign safety filter off or its draft approval to
`not_required`; create keys, grant scopes or change a key's budgets. A key can always make things more careful (lower a
cap, narrow the hours, freeze or pause a sender). The rest is done by a person in the app. A refused attempt answers
`403 forbidden` and is written to the audit trail.

Each key also has **daily budgets** (UTC day): 300 drafts written, 1000 leads enrolled into campaigns and 5 campaigns
started. Going over answers `429 budget_exceeded` with `details.resets_at`; nothing of the call is done. A workspace
admin can change a key's budgets in the app (`PATCH /v1/api-keys/{id} {budgets}`), up to 2000 / 5000 / 25.
`GET /v1/api-keys` shows each key's `budgets` and `usage_today`.

A key can also be **pinned to one sender**: it then sees and acts only as that sender (used by Ops Studio boxes: one founder, one sender).

`GET /v1/workspace` tells you which workspace and scopes your key has. Start there.

People use the web app with a session cookie (`POST /auth/login`); `POST /auth/password {current, new}` changes the
password (12 characters or more) and signs out the user's other sessions. An admin can reset one on the server with
`node dist/cli.js reset-password --email …` (password from `REACH_PASSWORD`), which ends every session of that user.
`GET /v1/members` lists who can open the workspace: its members, plus the organisation's owners and admins
(`via: "organization"`), who can open every workspace.

**Inviting someone (Relay sends no email).** `POST /v1/members {email, role}` without a password answers with
`invite: {url, expires_at}`: a one-time link, valid 7 days, to pass on yourself. The person opens it, sets their name and
a password (12+ characters) and is signed in (`GET /auth/invite/{token}` shows what the link is for,
`POST /auth/invite/{token} {password, name}` accepts it). `POST /v1/members/{user_id}/invite-link` issues a new link and
cancels the old one. `DELETE /v1/members/{user_id}` removes a member (organisation owners/admins can't be removed from a
single workspace).

## Quickstart

```bash
API=https://reach.opstudio.click
H="Authorization: Bearer $REACH_KEY"

# 1. Who am I?
curl -s $API/v1/workspace -H "$H"

# 2. Connect a LinkedIn account: open the returned url in a browser, sign in to LinkedIn there.
curl -s -X POST $API/v1/senders/connect-link -H "$H" -H 'content-type: application/json' \
  -d '{"redirect_url":"'$API'/auth/done","name":"Jane (founder)","country":"GB"}'
# → {"sender_id":"snd_…","url":"https://…","expires_at":"…","country":"GB","country_applied":true}
#   The sender turns "ok" once connected.
#   redirect_url must be on Relay's own address. From another app, use $API/auth/done: it shows
#   "LinkedIn connected. You can close this tab." (or that it failed or the link expired).
#   country: see "The account's country" below.

# 3. Set its limits and hours (optional: defaults are conservative)
curl -s -X PATCH $API/v1/senders/snd_… -H "$H" -H 'content-type: application/json' \
  -d '{"limits":{"invites_per_day":20,"invites_per_week":80},"hours":{"days":[1,2,3,4,5],"start":"09:00","end":"17:30"},"timezone":"Europe/London"}'

# 4. Add leads (idempotent, deduplicated)
curl -s -X POST $API/v1/leads -H "$H" -H 'content-type: application/json' -d '{"leads":[
  {"profile_url":"https://www.linkedin.com/in/alex-example","first_name":"Alex","company":"Northbridge","custom":{"topic":"hiring"}}]}'

# 5. Create a campaign with a sequence
curl -s -X POST $API/v1/campaigns -H "$H" -H 'content-type: application/json' -d @campaign.json

# 6. Enrol leads (each skipped lead comes back with its reason)
curl -s -X POST $API/v1/campaigns/cmp_…/leads -H "$H" -H 'content-type: application/json' -d '{"lead_ids":["lead_…"]}'

# 7. Check it, preview a message for a real lead, then start (needs the send scope)
curl -s $API/v1/campaigns/cmp_… -H "$H"                     # see ready.can_start and ready.blockers
curl -s -X POST $API/v1/campaigns/cmp_…/preview -H "$H" -H 'content-type: application/json' -d '{"node":"msg1","lead_id":"lead_…"}'
curl -s -X POST $API/v1/campaigns/cmp_…/start -H "$H" -H 'Idempotency-Key: start-cmp_…'
```


### The account's country

Each connected LinkedIn account works from one fixed IP address. Without a country, that address is placed in the country
of whoever opens the connect link, which is wrong when an assistant abroad or someone on a VPN opens it. Whoever makes the
link chooses instead:

| `country` on `POST /v1/senders/connect-link` | Country used |
|---|---|
| a code, e.g. `"GB"` (ISO 3166-1 alpha-2, any case; `"UK"` is refused, use `"GB"`) | that country |
| `null` | automatic: the country of whoever opens the link |
| left out, reconnect (`sender_id`) | the sender's stored `proxy_country`, else the workspace default |
| left out, new account | the workspace default, else automatic |

The workspace default is the setting `default_proxy_country` (`PATCH /v1/workspace` with
`{"settings":{"default_proxy_country":"GB"}}`; `null` clears it). The country used is kept on the sender as
`proxy_country`, with `proxy_country_applied`. In the app, the country is chosen in the dialog that opens from
Connect LinkedIn or Reconnect. A key with the `write` scope may set it.

The link is never refused because of the country. The answer says what happened: `country_applied: true` means the
country was sent with the link and accepted; `false` with a `warning` means the link works but the account will take the
country of whoever opens it, so tell the person before they use it. That is the case while the server runs with
`UNIPILE_PROXY_COUNTRY_MODE=off` (the default; `pin` sends the country, `pin_strict` also refuses a connection in any
other country when none is available there), or when the country is not one the provider offers.

## Sequences

A sequence is `{ "start": "<node id>", "nodes": { "<id>": <node>, … } }`. Each node has an `id` (same as its key) and a `type`.

| type | does | links |
|---|---|---|
| `view_profile` | visits the profile (they see "viewed your profile") | `next` |
| `follow` | follows the person | `next` |
| `like_post` | reacts to their most recent post (`reaction`, `within_days`); skipped if none | `next` |
| `invite` | connection request, optional `notes` (variants, ≤ 300 characters), auto-withdraw after `withdraw_after_days` | `yes` (accepted, or already connected) / `no` (not accepted within `accept_wait_days`) |
| `message` | message to a 1st-degree connection, `messages` (variants) | `next` |
| `inmail` | InMail (`subjects`, `messages`); needs credits or an open profile. On standard (Classic) LinkedIn senders the subject is not delivered | `next` |
| `endorse` | endorses up to `max_skills` skills | `next` |
| `withdraw_invite` | withdraws a pending invite | `next` |
| `delay` | waits `amount` `hours`/`days` | `next` |
| `condition` | `check`: `is_connected`, `has_replied`, `has_email`, `is_open_profile`, `accepted_invite` | `yes` / `no` |

A missing or `null` link ends the sequence for that lead. The engine keeps at least 3 hours between two actions on the
same lead. A reply from the lead (in any campaign of the workspace) stops them everywhere, unless the campaign turns `stop_on_reply` off.

**Example:**

```json
{
  "start": "view",
  "nodes": {
    "view":   { "id": "view", "type": "view_profile", "next": "invite" },
    "invite": { "id": "invite", "type": "invite", "notes": ["Hi {first_name|there}, enjoyed your post on {custom.topic|growth}. Happy to connect."],
                "withdraw_after_days": 21, "accept_wait_days": 14, "yes": "wait1", "no": null },
    "wait1":  { "id": "wait1", "type": "delay", "amount": 1, "unit": "days", "next": "msg1" },
    "msg1":   { "id": "msg1", "type": "message", "messages": [
                  "Thanks for connecting, {first_name}! {Curious|Quick question}: how is {company} handling outbound today?",
                  "Good to be connected, {first_name}. What is {company} focused on this quarter?" ], "next": "wait2" },
    "wait2":  { "id": "wait2", "type": "delay", "amount": 4, "unit": "days", "next": "check" },
    "check":  { "id": "check", "type": "condition", "check": "has_replied", "yes": null, "no": "msg2" },
    "msg2":   { "id": "msg2", "type": "message", "messages": ["Hi {first_name}, worth a short call next week?"], "next": null }
  }
}
```

**Personalisation.** `{first_name}`, `{last_name}`, `{full_name}`, `{company}`, `{title}`, `{location}`, `{headline}`, `{email}`,
`{custom.<field>}`. Fallback when empty: `{first_name|there}`. Spintax: `{Hi|Hello|Hey}`. Literal braces: `{{` `}}`.
A message whose variable has no value and no fallback is **not sent**: the lead is held and the problem is reported.

**Variants (A/B).** Give several strings in `notes` / `messages`. Each lead gets one, evenly split and stable per lead;
`GET /v1/stats?campaign_id=…&group_by=variant` compares them.

**Numbers.** `GET /v1/stats` gives totals for a window (`from`, `to`; default the last 30 days), filtered by `sender_id`,
`campaign_id` or several campaigns `campaign_ids=cmp_a,cmp_b` (only those count). Besides the per-event counts it has
`contacted` (leads whose first invite, message or InMail fell in the window) and `interested` (leads with a reply
classified interested, once each). `group_by=day|sender|campaign|step|variant` adds `groups[]`: `{key, …the same
fields}`, sorted by key; for `campaign` the key is the campaign id, or `(none)` for events outside a campaign, and a
campaign without events in the window is absent (count it as zero). `GET /v1/overview` takes the same campaign filters.

**Validation.** `POST /v1/sequences/validate` returns every problem at once, each a sentence with the node id:
broken links, loops, unreachable steps, a `message` reachable by someone not connected (put it after an invite's `yes`
or an `is_connected` check, or use `inmail`), notes over 300 characters (rendered with a long sample name), unknown variables.
A draft may hold an incomplete sequence; a campaign only starts with zero problems.

## Campaign lifecycle

- `POST /v1/campaigns` creates a **draft**. Leads added to a draft are enrolled at once (`pending`) and checked against the safety filters.
- `GET /v1/campaigns/{id}` is the whole state in one call: settings, sequence, senders, lead counts per state, per-step counts (and per variant), and `ready: {can_start, blockers, sequence_problems}`.
- `POST …/approve` (send scope) freezes the reviewed version. In workspaces with `settings.approval_required`, only approved campaigns start. Changing the sequence afterwards returns it to draft.
- `POST …/start` (send scope) starts sending. `…/pause` stops new actions at once; `…/resume` continues; `…/archive` stops everyone for good.
  A campaign whose senders aren't connected yet can still start; the response (and `GET /v1/campaigns/{id}`) then carries
  `warnings: ["No connected sender: nothing will send until one connects."]`, and nothing goes out until one connects.
- `settings.daily_new_leads` caps how many leads the campaign *starts* per day (a lead's first action counts; its later
  steps don't), in the campaign's `timezone` or else the sender's. Leads over the cap wait for the next day.
- **Previews.** `POST …/preview {node, lead_id}` renders a saved step exactly as the engine will send it to that lead.
  Add `template` (and `subject`, `node_type`) to render text that isn't saved yet, e.g. while editing: it uses the same
  seed the engine will use for that node id, so the spintax pick you see is the one that will be sent.
- The sequence can only change while not running (pause first). On a paused campaign, steps where leads are waiting must keep their ids.

**Senders and leads.** A campaign can have several senders; each lead is assigned to one sender and stays with them.
Limits are per sender and shared by all its campaigns.

## Guardrails

Every action passes these checks, in order, at the moment it would run:

| Check | Default | Set where |
|---|---|---|
| Sender connected and not paused | n/a | `GET /v1/senders` shows `status` |
| Working hours | Mon–Fri 09:00–18:00, sender's time zone | `PATCH /v1/senders/{id}` `hours`, `timezone` |
| Daily cap per action | invites 25 · messages 100 · InMails 30 · profile views 80 · follows 40 · likes 40 · endorsements 20 · withdrawals 30 | `limits` |
| Profile lookups a day | 100 | `limits.lookups_per_day` |
| New leads a campaign starts a day | no cap | campaign `settings.daily_new_leads` |
| Invite warm-up | starts at 10/day, +1/day up to the daily cap | `limits.warmup` |
| Weekly invites | 100 a calendar week, Monday–Sunday in the sender's time zone (rolling 7 days if the `weekly_invites` freezing rule is off) | `limits.invites_per_week` |
| Random gap between a sender's actions | 4–15 minutes | `limits.min_gap_seconds`, `max_gap_seconds` |
| Freezes (weekly invites, total actions a day, LinkedIn pushback, pending invitations, disconnects, by hand) | see below | `freeze_rules`, `/freeze`, `/unfreeze` |
| Lead's own working hours (optional) | off | campaign `send_in_lead_timezone` |
| Lead is do-not-contact / blocklisted | n/a | `PATCH /v1/leads/{id}`, `/v1/blocklist` |
| Lead already replied anywhere in the workspace | stop | campaign `stop_on_reply` |
| Lead in another running campaign | skip | `exclude_in_other_campaigns` |
| Lead already contacted by the workspace | skip | `exclude_contacted_before` |
| Lead already talking with another sender | skip | `exclude_other_sender_conversations` |

An API key can only move these towards more careful (a lower cap, a longer gap, narrower hours, a stricter freezing
rule); raising them and lifting a freeze is done by a person in the app.

`GET /v1/senders` returns, for each sender, `today.used`, `today.caps` (warm-up included) and `today.remaining`, so you can
plan volume before you start. `today.activity` lists what the sender did today and what came back, oldest first, in its own
local day: `{ "at": "<ISO time>", "kind": "invite" | "message" | "inmail" | "view_profile" | … | "accepted" | "reply" }` —
enough to draw its day or to check pacing. The campaign safety filters can be tightened by any key; only a workspace admin, in the app,
can switch one off.

**Profile lookups vs profile views.** Before an invite, a profile view or an endorsement (and whenever a lead's LinkedIn id
is unknown) the engine reads the lead's profile: that is a *lookup*, counted in `today.used.profile_lookup` and capped by
`limits.lookups_per_day` (default 100, max 250). A *profile view* (`view_profile` step) is the visit the lead is notified of,
capped separately by `profile_views_per_day`. When lookups run out, steps that need one wait for the sender's next day.

**What a refusal means.** Sender-side refusals (hours, caps, cool-down) are not errors: the action simply waits for the
next allowed moment. Lead-side refusals skip or stop that lead with a `reason`, visible in `GET /v1/campaigns/{id}/leads`
and in `lead.skipped` / `lead.stopped` events.

**Sends are never duplicated.** If a send times out and we can't tell whether LinkedIn took it, the action is marked
`unknown` and checked by reading the account before anything is retried (`action.unknown` event).

### Freezing rules

On top of the caps above, a sender can be **frozen**: everything waits (`kinds: ["all"]`) or only some actions do (e.g.
`["invite"]`), until a time or until a condition clears. The engine freezes on its own when a rule trips, and a person can
freeze and unfreeze by hand. Each sender carries its `freeze` (`state`: `none`, `frozen` or `partly_frozen`, the `active`
freezes with `reason` and `until`, and on `GET /v1/senders/{id}` the last 60 days of `history`); every freeze and lift is a
`sender.frozen` / `sender.unfrozen` event and webhook.

| Rule (`rule`) | Trips when | Freezes | Lifts | Setting (`freeze_rules`) |
|---|---|---|---|---|
| `weekly_invites` | invites this week (since Monday 00:00, sender's time) reach `limits.invites_per_week` | invitations | Monday 00:00, sender's time | `weekly_invites` (on) |
| `daily_actions` | actions today (every kind) reach the total | everything | the sender's next day | `actions_per_day` (200; 0 = off) |
| `pushback` | LinkedIn answers with a rate or limit error | everything | after 24h; 48h if it happens again within 7 days; then 7 days | `pushback_hours` ([24, 48, 168]) |
| `pending_invites` | unanswered invitations exceed the threshold | invitations. For a checked sender old ones are withdrawn when a person accepts (see the account check below); for a sender from before the check, counted every few hours and the oldest withdrawn automatically | when pending drop to max − margin | `pending_invites_max` (500; 0 = off), `pending_invites_margin` (50) |
| `disconnected` | the account disconnects, asks for a verification, or is restricted | everything | when it is reconnected | always on |
| `manual` | `POST /v1/senders/{id}/freeze {until?, note?, kinds?}` | what you chose | at `until`, or `POST /v1/senders/{id}/unfreeze` (a person, in the app) | — |

Change rules with `PATCH /v1/senders/{id}` `{"freeze_rules":{"actions_per_day":150,"pending_invites_max":400}}`.
`POST /v1/senders/{id}/unfreeze` (people only: an API key is refused) lifts every freeze (or `{"rules":[…]}` only some) except `disconnected`, which needs a
reconnect; an automatic rule freezes again if its cause is still there. LinkedIn pushback can't be switched off, only its
durations changed. A person's reply from the inbox passes the volume freezes (daily actions, weekly invites, pending
invitations) but waits for a pushback, a disconnect or a freeze set by hand.

### Account check and old pending invitations

When a LinkedIn account connects or reconnects, Relay reads it once: plan, number of connections and the invitations
already pending (sent, unanswered). Never on a schedule: each 100 pending invitations cost one of the account's daily
lookups, so from then on the count is kept locally (+1 for an invitation sent, −1 for one accepted or withdrawn).
`POST /v1/senders/{id}/safety-check` runs it by hand, at most once an hour. The result is `safety` on the sender, with
one status line (`safety.line`): `Ready`, `Too many pending invites (N): withdraw old ones first`, or `Not checked`.

| What the check finds | What it sets |
|---|---|
| fewer than 100 connections, or the number is unknown | starting limits: warm-up from 5 invites a day, 15 a day at most |
| 100 connections or more | warm-up from 10 a day (+1 a day), 25 a day at most. A paid plan does not raise this |
| every checked sender | random gap of 5–20 minutes between actions, at most 20 withdrawals a day, and invitations and withdrawals share the day's invitation limit |
| 200–499 pending invitations | invitations capped at 10 a day until fewer than 200 are pending |
| 500 or more pending | invitations paused (the `pending_invites` freeze) until fewer than 300 are pending |
| not checked yet (a new sender whose account could not be read) | no invitations until a check succeeds |

Starting limits are only ever lower than the defaults and are set once, at the first check; change them afterwards with
`PATCH /v1/senders/{id}` as before. A sender connected before this check existed (`safety.state` = `legacy`) works
exactly as before until it is reconnected or checked by hand.

Old invitations are never withdrawn on their own for a checked sender. `GET /v1/senders/{id}/pending-invites` says how
many invitations from before Relay are older than 21 days; `POST /v1/senders/{id}/pending-invites/withdraw
{"confirm": true}` (send scope; without `confirm` it is a dry run) queues them. The engine withdraws them one at a
time, oldest first, in the sender's working hours, with the random gap, inside the day's shared limit. It stops at
once on LinkedIn pushback, a restriction, a verification, a disconnect, a freeze or a withdrawal that got no answer:
what is left is cancelled (`safety.withdrawals.stopped` says why) and the same call starts it again once the account
is fine. No invitation is withdrawn twice, and a withdrawal with an unknown outcome is never retried: the next check
reads LinkedIn and settles it. `DELETE /v1/senders/{id}/pending-invites/withdraw` cancels what is still queued.

### Sending in the lead's time zone

With the campaign setting `send_in_lead_timezone: true`, an outreach action goes only when the sender's working hours hold
**both** in the sender's time zone and in the lead's: a 09:00–18:00 sender in London reaches a lead in Chicago between
15:00 and 18:00 London time. The lead's time zone (`timezone` on the lead) comes from their LinkedIn location ("Austin,
Texas, United States" → America/Chicago; countries with several zones need a state or city) or is set by hand
(`PATCH /v1/leads/{id} {"timezone":"America/Chicago"}`, which then stays). A lead with no known time zone, or whose window
never meets the sender's within a week (Sydney and San Francisco on office hours), follows the sender's hours alone.

### Variant winners

A message, InMail or invite step with variants can pick a winner by itself:
`"auto_winner": {"after_sends": 100, "metric": "reply_rate", "min_lift": 20}` (acceptance_rate for invites). Once every
variant has `after_sends` sends and the best rate beats the next best by `min_lift` percent, new leads get only the
winner; leads already on a variant keep it. The pick is a `campaign.variant_winner` event, shows in
`GET /v1/campaigns/{id}` as `variant_winners`, and `GET /v1/stats?campaign_id=…&group_by=variant` marks it `winner: true`.

### Auto-tags from replies

Every reply is classified (English, French and Dutch phrases; deterministic) and its lead tagged once:
`auto:interested`, `auto:not_interested`, `auto:out_of_office` or `auto:other`, the latest reply winning. Filter leads with
`GET /v1/leads?auto_tag=interested` (or `tag=auto:interested`). An out-of-office is not a reply: outreach continues.

### Where leads come from

A CSV (`POST /v1/leads/import`), a CSV or sheet by link (`POST /v1/leads/import-url`), a pull from a connected CRM,
sheet or database (see Integrations), or the API (`POST /v1/leads`).
Where it is enabled, also "Find leads" (next section).
Relay never uses a connected LinkedIn account to search for or collect leads.

## Finding leads with credits

Search for new people and add them to a list, paid with lead credits. The people come from an outside data source,
never from a connected LinkedIn account. The feature is switched on per Relay server: where it is off, every route
below answers `501 not_configured` and `GET /v1/workspace` has no `features.find_leads`.

**Credits.** 1 credit = 1 lead actually added to a list. Credits belong to the organisation and work in all its
workspaces. Every connected LinkedIn account brings 500 credits per calendar month (UTC); they reset on the 1st and
do not carry over. A newly connected account adds its 500 at once; removing one takes nothing back that month. Extra
credits come in packs of 1,000, added on request, and never expire. Included credits are spent first.
`GET /v1/credits` returns `total`, `included`, `purchased`, `resets_at`, the organisation's daily limit and, for an
API key, its own daily budget. `GET /v1/credits/ledger` (organisation owners and admins) lists grants, spends and
refunds.

**1. Search (free).**

```bash
curl -s -X POST $API/v1/find-leads/searches -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"job_titles":["Head of Sales","VP Sales"],"countries":["United Kingdom"]}'
# → {"search_id":"lsr_…","total":3184,"preview":[{"name":"Amelia R.","title":"Head of Sales","company":"Northwind Logistics","location":null}, …],
#    "added":0,"credits":{"total":2340,"included":1340,"purchased":1000,…}}
```

Filters: `job_titles` **or** `keywords` (one of the two; keywords are slower), `countries`, `states`, `cities`,
`company_names` (current employer). Nothing else is accepted: there is no filter for seniority, years of experience,
industry, funding stage or excluded companies. The preview shows up to 25 people as first name and last initial,
title, company and (when the source has it) location; `total` is the source's match count when it gives one, and
includes people without a LinkedIn profile, who can't be added. The search is saved (`GET /v1/find-leads/searches`);
the same filters run again are the same search, so adding later carries on where it stopped. At most 60 searches per
organisation per hour.

**2. Add to a list (costs credits).**

```bash
curl -s -X POST $API/v1/find-leads/searches/lsr_…/add -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"count":100,"list_name":"UK heads of sales"}'        # or "list_id":"list_…"; "dry_run":true changes nothing
# → {"id":"ljb_…","status":"queued","requested":100,"reserved":100,"limited_by":null,…}
```

Needs the `write` scope. Relay reserves the credits and works in the background through the next `count` people of
the search. Someone already in your workspace (same LinkedIn member id, public id or profile URL) is put on the
list as they are, without a charge (`existing_added_to_list`); someone on the organisation's suppression list is
skipped; a profile that can't be read counts as failed. **Only a new lead that
was stored and put on the list costs a credit**; the rest of the reservation is refunded when the job ends. If the
balance is smaller than `count`, what it allows is added and `limited_by` says why (`balance`, `daily_limit` or
`key_budget`); with no credits at all the call is refused (`402 insufficient_credits`). One job at a time per search.

```bash
curl -s $API/v1/find-leads/jobs/ljb_… -H "Authorization: Bearer $KEY"
# → {"status":"done","added":86,"existing_added_to_list":9,"skipped_suppressed":0,"failed":5,"credits_used":86,"credits_refunded":14,…}
curl -s -X POST $API/v1/find-leads/jobs/ljb_…/cancel -H "Authorization: Bearer $KEY"   # leads already added stay; the rest is refunded at once
```

A lead added this way has `source: "credits"`, `source_fetched_at` and a `profile` object (headline, summary,
location, positions, education, skills, languages) on `GET /v1/leads/{id}`. No email or phone number is fetched.

**Limits.** At most 1,000 people per job and 5,000 per organisation per UTC day. An API key also has a daily budget,
`lead_credits_per_day` (default 500, at most 5,000; a workspace admin changes it with the key's other budgets): when
it is used up the call is refused with `429 budget_exceeded` and the time it resets. Credits a job refunds go back
to the key's budget. Every spend is in the audit trail with the user or key behind it.

**Suppression.** `POST /v1/credits/suppressions` with `{"linkedin":"https://www.linkedin.com/in/…"}` or
`{"email":"…"}` (organisation owners and admins) makes sure that person is never added through credits again, in
any workspace. Deleting a lead (`DELETE /v1/leads/{id}`) also deletes the profile data fetched for it.

## Agent-written messages and approvals

Relay can send each lead a message written for that lead alone. Relay does not write it and calls no AI model: your own
agent (Claude or any MCP client, or a script) writes the text with a Relay API key, a person approves it, and Relay's
engine sends it inside the same guardrails as everything else.

**1. Mark the step.** An `invite`, `message` or `inmail` step with `draft` takes its text per lead from a draft instead of
the template:

```json
{ "id": "msg1", "type": "message", "draft": { "fallback": "none", "brief": "Refer to their latest post; one question." }, "next": null }
```

- `fallback: "none"` (default): a lead with no approved draft **waits at the step**. Nothing is sent, and the wait uses
  none of the sender's limits, lookups or gaps. Other leads go on as usual.
- `fallback: "template"`: a lead with no draft at all gets the step's template (`messages` / `notes`); a draft that is
  still waiting for approval is waited for.
- In `build_sequence` steps: `"draft invite"`, `"draft message: <brief>"`, `"draft inmail"`, and
  `{"step": "draft message: …", "fallback": "Thanks for connecting, {first_name|there}!"}`.

**2. Write the drafts.** `GET /v1/campaigns/{id}/drafts/needed?node=&due_only=` lists the leads that need a draft, with
what Relay knows about each: profile fields, tags, custom fields, the earlier messages of the thread, and the last
rejected draft with the reviewer's note. `due` marks leads standing at the step now. Then, per lead:

```bash
curl -s -X POST $RELAY/v1/campaigns/$CAMPAIGN/drafts -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"lead_id":"lead_…","node":"msg1","body":"Thanks for connecting, Ana. Your post on onboarding checklists made me curious: how long does a new hire at Lumen take to ship?"}'
```

A draft is the exact text: no variables, no spintax. One live draft per lead and step: writing again replaces it and
approval starts again. `dry_run: true` checks without saving. Every draft is checked when it is written, when a person
edits it, and again by the engine just before it is sent; a refusal is `422 draft_invalid` with every problem in
`details`:

| Check | Rule |
|---|---|
| Length | invitation note 300 characters · message 8000 · InMail 8000, subject 200 |
| Not empty | whitespace does not count |
| No unresolved placeholder | `{first_name}`, `{{name}}`, `[First Name]`, `<company>`, `%NAME%` and the like are refused |
| Same text for many leads | the same text (the lead's own name and company aside, case and punctuation ignored) for at most 3 leads of a campaign; shared wording belongs in the step's template |
| The lead | not do-not-contact, still in the campaign, the step not already done for them |

A draft that fails the send-time check is rejected with the reason and the lead waits for a new one.

**3. Approve.** A draft's `status` goes `pending` → `approved` | `rejected`, then `sent` when the engine has used it
(`superseded` when a newer draft replaced it).

| Who wrote it | What happens |
|---|---|
| An API key (REST or MCP) | `pending`. A person approves it in the app (**Approvals**), or a *different* key that carries the `approve` scope does. A key never approves a draft it wrote. |
| An API key, in a campaign with `settings.approval: "not_required"` | approved automatically once it passes the checks. Only a workspace admin, in the app, can set `not_required`; the default is `required`. |
| A person signed in to the app | approved on save. |

`GET /v1/drafts` is the queue (pending by default, oldest first, the lead beside the text; `counts.pending` is its size;
`status=approved|rejected|sent` looks back). `POST /v1/drafts/{id}/approve` approves, with `{body}` a person approves
with an edit (the first text is kept as `original_body`, with who edited it). `PATCH /v1/drafts/{id}` saves an edit
without deciding. `POST /v1/drafts/{id}/reject {note}` rejects, or takes back an approved draft that has not been sent.
`POST /v1/drafts/bulk {action, ids}` decides up to 100 drafts by id. Approving, rejecting and editing need the `approve`
scope: members and admins of the workspace have it, viewers and ordinary keys do not.

**4. The engine sends.** When a lead reaches the step and has an approved draft, the engine sends that text under the
sender's limits, hours and gaps, and marks the draft `sent`. `GET /v1/campaigns/{id}/leads` shows a waiting lead with
`waiting_for: "draft" | "approval"`, and `GET /v1/campaigns/{id}` carries `drafts` (`pending`, `approved`, `rejected`,
`sent`, `waiting_for_draft`, `waiting_for_approval`). The sent event (`invite.sent`, `message.sent`, `inmail.sent`)
carries `draft_id` and `source: "draft"`; reply rates per step are in `GET /v1/stats?group_by=step`. Events
`draft.written`, `draft.approved` and `draft.rejected` (also as webhooks) let you tell a reviewer that something waits.

**The audit trail.** `GET /v1/audit` (workspace admins, in the app) lists, newest first, every draft written, edited,
approved or rejected, every budget refusal, and every refused attempt by a key to loosen a sender, lift a freeze, edit
or approve its own draft, each with the actor and, for a key, the key id.

**Not part of this:** an agent cannot send an inbox reply for approval through this queue (replies still go through
`POST /v1/conversations/{id}/reply` with the `send` scope), and Relay never generates text itself.

## Inbox

`GET /v1/conversations` lists every conversation of every sender (filters: `sender_id`, `campaign_id`, `unread`,
`awaiting_reply`, `label`, `search`). `GET /v1/conversations/{id}` returns the messages. `POST /v1/conversations/{id}/reply`
(send scope) queues a reply; the engine sends it within about a minute, inside the sender's daily message limit.
Send an `Idempotency-Key` and a retried request will never send twice.

More filters: `auto_tag` (the engine tags leads from their replies: `interested`, `not_interested`, `out_of_office` …)
and `classification` (any inbound message classified `interested | not_interested | auto_reply | other`). Each
conversation lists its lead's `auto_tags`.

- **Bulk:** `POST /v1/conversations/bulk {action, ids | filter, label?}` with `mark_read`, `archive`, `unarchive`,
  `add_label`, `remove_label` (at most 1000 per call); `POST /v1/conversations/{id}/archive` and `/unarchive` for one.
- **Saved replies:** `GET/POST /v1/saved-replies`, `PATCH/DELETE /v1/saved-replies/{id}`. Same variables as sequences.
  `POST /v1/conversations/{id}/render-saved-reply {saved_reply_id}` fills them for that lead and returns the text
  (`ready: false` when a value is missing); nothing is sent until you POST the reply.
- **Act on the thread's lead:** `POST /v1/conversations/{id}/add-to-campaign {campaign_id}` (normal safety filters),
  `/block {kind: lead|company}` and `/do-not-contact {value}`; blocked and do-not-contact leads leave every running campaign.

## Templates and duplicating

`GET /v1/campaign-templates` lists the built-in starters (`tpl_builtin_connect_followups`, `tpl_builtin_warmup_connect`,
`tpl_builtin_inmail_open_profiles`, `tpl_builtin_reengage_connections`) and the workspace's own.
`POST /v1/campaign-templates/{id}/create-campaign {name, sender_ids?}` makes a draft from one;
`POST /v1/campaigns/{id}/save-as-template {name}` keeps a campaign's sequence and settings (no leads, no senders);
`POST /v1/campaign-templates {name, sequence}` creates one directly. `POST /v1/campaigns/{id}/duplicate {name?, include_leads?}`
copies a campaign into a new draft; with `include_leads` the same leads are enrolled through the safety filters, so
leads still running in the source come back `skipped` with the reason.

## Exports

`GET /v1/exports/leads.csv`, `/campaign-leads.csv?campaign_id=…`, `/stats.csv?group_by=day|sender|campaign|step|variant` (also `campaign_ids`)
and `/conversations.csv`: streamed CSV, UTF-8 with a BOM (Excel reads accents), formula-like cells neutralised.
`POST /v1/exports/link {kind, params}` returns a signed download URL valid for 15 minutes, for clients that can't
stream (the MCP tool `export_csv` uses it for large exports).

## Health

`GET /v1/health` answers "is everything running": the engine heartbeat, the queues (due and overdue leads, scheduled
and unknown actions, webhook deliveries), every sender (status, freeze, last action, last error, inbox poll age) and the
provider, each check `ok | warn | bad` with what to do. `GET /v1/health/organization` (owners/admins) shows every workspace.

## Events and webhooks

Everything that happens is an event: `GET /v1/events?after=<event id>` returns them oldest first after the cursor
(store `next_after`, poll again; nothing is missed or repeated).

Types: `sender.connected`, `sender.status_changed`, `sender.limit_hit`, `sender.cooldown`, `campaign.created`,
`campaign.approved`, `campaign.started`, `campaign.paused`, `campaign.resumed`, `campaign.completed`, `lead.enrolled`,
`lead.skipped`, `lead.stopped`, `lead.finished`, `lead.failed`, `profile.viewed`, `lead.followed`, `post.liked`,
`skills.endorsed`, `invite.sent`, `invite.accepted`, `invite.withdrawn`, `message.sent`, `inmail.sent`,
`message.received`, `reply.received`, `action.failed`, `action.unknown`, and `webhook.test`.

**Webhooks** push the same events: `POST /v1/webhooks {url, events: ["reply.received","invite.accepted"] | ["*"], campaign_ids?}`.
The response contains the signing `secret` (shown once). Each delivery is a `POST` with a JSON body:

```json
{ "id": "evt_01j…", "type": "reply.received", "workspace_id": "ws_…", "campaign_id": "cmp_…", "sender_id": "snd_…",
  "lead_id": "lead_…", "at": "2026-11-05T14:02:11.000Z",
  "data": {
    "lead": { "member_id": "ACoA…", "public_id": "alex-example", "profile_url": "https://www.linkedin.com/in/alex-example",
              "first_name": "Alex", "last_name": "Example", "headline": "COO", "company": "Northbridge" },
    "sender": { "id": "snd_…", "name": "Jane (founder)" }, "campaign": { "id": "cmp_…", "name": "Founders Q4" },
    "text": "…", "conversation_id": "conv_…", "message_id": "msg_…", "provider_message_id": "…",
    "received_at": "2026-11-05T14:02:09.000Z", "channel": "dm", "classification": "interested", "node": "msg1", "variant": 0 } }
```

Every event with a lead carries `data.lead`, with a sender `data.sender`, with a campaign `data.campaign`, so a receiver
needs no second call. Message events (`message.sent`, `inmail.sent`, `message.received`, `reply.received`) also carry
`text`, `conversation_id`, `message_id` (Relay's), `provider_message_id`, `sent_at` (outbound) or `received_at` (inbound),
and `channel`: `inmail` when the conversation was opened by an InMail, else `dm`.

A key or webhook **pinned to a sender** sees that sender's events plus the `campaign.*` events of campaigns that use it.

and the header `Relay-Signature: t=<unix seconds>,v1=<hex>` (the same value is also sent as `Reach-Signature`, the name before the Relay rename, so older receivers keep working), where `v1 = HMAC-SHA256(secret, "<t>.<raw body>")`.
Verify it, reject `t` older than 5 minutes, and dedupe on `id`: retries reuse it. Answer 2xx within 10 seconds;
failed deliveries are retried with backoff for 24 hours (`GET /v1/webhooks/{id}/deliveries` shows each attempt).
`POST /v1/webhooks/{id}/test` sends a `webhook.test` event.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return Math.abs(Date.now() / 1000 - Number(t)) < 300 && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
```

### How replies and new connections reach Relay

Relay checks each connected sender's LinkedIn inbox every 10–20 minutes between 07:00 and 22:00 in the sender's time
zone (hourly at night), opening only conversations with new activity, and checks for new connections every 3–6 hours
while invites are pending. When the server is reachable from the internet, LinkedIn activity is also pushed to Relay
as it happens. Both paths are deduplicated: a message is recorded once, and `reply.received` fires once. Without the
push path, a reply can take up to about 20 minutes (an hour at night) to appear and to stop the lead.

Each workspace's LinkedIn accounts are kept apart from every other workspace's; a connect link always adds the account
to the workspace that asked for it.

## Integrations

Relay sends what happens on LinkedIn to the tools a team already uses, and brings leads back from them
(`GET /v1/integrations/catalogue` lists everything):

- **CRMs**: HubSpot, Pipedrive, Salesforce, Attio, Close, Zoho CRM, Copper, monday CRM — create or update the person
  (`push_lead`) and log each touch as a note or task (`log_activity`).
- **Cold email**: Instantly, Smartlead, lemlist, Apollo, Reply.io, Woodpecker, Saleshandy, Mailshake, Snov.io — enrol
  the lead in an email campaign or sequence (`add_to_sequence`); Instantly, Smartlead and Apollo can also send leads
  back.
- **Sheets and databases**: Airtable, Notion, Google Sheets (rows in, people with a LinkedIn URL out).
- **Enrichment**: Apollo (`enrich` fills a lead's email, title and company), Clay (a row per lead in a Clay table).
- **Chat**: Slack, Microsoft Teams, Discord — a post per reply (`notify`).
- **Automation**: Zapier, Make and n8n subscribe with REST hooks (`POST /v1/hooks/subscribe`, `DELETE /v1/hooks/{id}`,
  samples at `GET /v1/hooks/sample/{event}`); their actions are ordinary Relay API calls (`GET /v1/integrations/automation`).
- **CSV from a link**: `POST /v1/leads/import-url` (a Google Sheet shared by link works).

Connect with `POST /v1/integrations` (`connector`, `credentials`, `settings`). The credentials are tested against the
tool first; if the tool refuses them nothing is saved. They are stored encrypted and never returned (only the last
four characters). Salesforce, Zoho CRM and Google Sheets connect through their own sign-in page
(`POST /v1/integrations/oauth/start`).

**Triggers** say which events run which actions, e.g. a CRM by default pushes the lead and logs the touch on
`invite.accepted` and `reply.received`; a cold-email tool enrols on `lead.finished` (the sequence ran out without a
reply); chat posts on `reply.received`. `campaign_ids` limits a connection to some campaigns. The **field map** maps
the tool's fields to lead fields (`email`, `company`, `linkedin_url`, `custom.score` …), the event's `campaign`,
`sender` or `text`, or a template like `"{first_name} at {company}"`.

**Deliveries** (`GET /v1/integrations/{id}/deliveries`) show every event sent to the tool, which actions finished,
the tool's ids and any error. Network errors and 5xx retry for about 21 hours (1m, 5m, 30m, 2h, 6h, 12h); a rate limit
waits for the tool's Retry-After; a refused credential stops the connection (status `error`) until it is fixed and
tested; a retry never repeats an action that already finished, and a second push for the same lead updates the
record instead of creating a duplicate. Send chosen leads by hand with `POST /v1/integrations/{id}/push`, and bring
people in with `POST /v1/integrations/{id}/pull`.

## Idempotency

Every `POST`, `PUT`, `PATCH` and `DELETE` accepts `Idempotency-Key: <any unique string>`. Repeating the request with the
same key returns the first response (header `idempotent-replayed: true`) without doing anything again. The same key with a
different body is refused (`409 idempotency_mismatch`). A request that failed frees its key, so you can retry it. Use a
key whenever a retry could double an effect: starting campaigns, sending replies, adding leads.

## Errors

```json
{ "error": { "code": "invalid_state", "message": "The campaign is not ready to start.",
             "hint": "The sequence has 1 problem(s); PUT a fixed sequence …", "details": { … } } }
```

| HTTP | code | meaning |
|---|---|---|
| 400 | `invalid_input` | the request doesn't match the schema; `details` lists the fields |
| 401 | `unauthorized` | missing or revoked key |
| 403 | `forbidden` | the key lacks a scope, is pinned to another sender, or the action is admin-only (for a key: raising a sender's limits, lifting a freeze, approving its own draft) |
| 422 | `draft_invalid` | a per-lead draft failed a content check; `details` lists every problem |
| 429 | `budget_exceeded` | the key used its daily budget (drafts, enrolments or campaign starts); `details.resets_at` says when it resets |
| 404 | `not_found` | no such thing in this workspace |
| 409 | `invalid_state` / `conflict` / `idempotency_mismatch` | not allowed in the current state (see `hint`) |
| 422 | `sequence_invalid` | `details` is the list of sequence problems |
| 429 | `rate_limited` | over 600 requests/minute for the key; wait `Retry-After` |
| 501 | `not_configured` | the LinkedIn provider isn't configured on this server |
| 5xx | `internal` / `provider_unavailable` | retry with the same Idempotency-Key |

`hint` is always written for whoever reads it next, person or AI: it says what to do.

## Pagination

List endpoints return `{ "data": [...], "next_cursor": "…" | null }`; pass `cursor=<next_cursor>` for the next page.
The event feed uses `after=<event id>` and returns `next_after` and `has_more`.

## Limits of the API itself

600 requests per minute per key. Bulk calls: 1000 leads per upsert, 5000 per enrolment or CSV import.

## Names: Relay and the older "Reach"

The product was called Reach while it was being built and was renamed Relay on 2026-11-05. For one release the old names keep working:

- API keys issued before the rename start with `rk_live_`; they stay valid. New keys start with `rl_live_`.
- Webhook deliveries carry `Relay-Signature` and, with the same value, the legacy `Reach-Signature` (also `Relay-Event-Id` / `Reach-Event-Id`, `Relay-Event-Type` / `Reach-Event-Type`).
- MCP resources are `relay://…`; the `reach://…` URIs still answer.
- The Python client is `relay_client.py` (`Relay`, `RelayError`); `reach_client.py` (`Reach`, `ReachError`) is an alias.

Internal names are unchanged and not part of the API: the services `reach-api` and `reach-worker`, the directory `/srv/reach`, and the database and schema `reach`.
