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.

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:

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

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 }

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.

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):

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:

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.