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 · machine-readable: /v1/openapi.json
- This guide as Markdown, for feeding to an AI: /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 workspacewrite: 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 repliesapprove: 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)
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
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:
{
"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/campaignscreates 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), andready: {can_start, blockers, sequence_problems}.POST …/approve(send scope) freezes the reviewed version. In workspaces withsettings.approval_required, only approved campaigns start. Changing the sequence afterwards returns it to draft.POST …/start(send scope) starts sending.…/pausestops new actions at once;…/resumecontinues;…/archivestops everyone for good. A campaign whose senders aren't connected yet can still start; the response (andGET /v1/campaigns/{id}) then carrieswarnings: ["No connected sender: nothing will send until one connects."], and nothing goes out until one connects.settings.daily_new_leadscaps how many leads the campaign starts per day (a lead's first action counts; its later steps don't), in the campaign'stimezoneor 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. Addtemplate(andsubject,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).
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).
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.
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:
{ "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_sequencesteps:"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:
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?}withmark_read,archive,unarchive,add_label,remove_label(at most 1000 per call);POST /v1/conversations/{id}/archiveand/unarchivefor 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: falsewhen 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:
{ "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.
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 (
enrichfills 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 atGET /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
{ "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 withrl_live_. - Webhook deliveries carry
Relay-Signatureand, with the same value, the legacyReach-Signature(alsoRelay-Event-Id/Reach-Event-Id,Relay-Event-Type/Reach-Event-Type). - MCP resources are
relay://…; thereach://…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.