Build on LinkOutreach
Drive the product from your own code, from Claude, or from ChatGPT. REST and MCP share one auth model, one organization key, and one guarantee: everything the interface can do, an outside caller can do.
This page is the mechanics. The endpoints and the tools are on their own tabs, and both are generated from the running server rather than written by hand.
Every endpoint, its fields and its responses, with a playground that sends a real call from your browser.
The two connectors, the whole catalogue, and which mount a given tool is on.
Authentication
Create a key in Integrations. It is scoped to your whole organization. Send it as a Bearer token.
curl https://linkoutreach.co/api/v1/accounts \ -H "Authorization: Bearer sk_lis_..."
Base URL: https://linkoutreach.co/api/v1. All bodies are JSON · send Content-Type: application/json.
Workspaces & multiple accounts
Your organization can hold many workspaces (for an agency, one per client) · each is fully isolated: its own LinkedIn accounts, lists, sequences and inbox. One key manages them all.
- · List or create workspaces with
GET / POST /workspaces. - · Target a specific workspace on any call by adding
?workspace_id=<id>(REST) or theworkspace_idtool argument (MCP). - · On REST you can instead send a
X-Workspace-Idheader. With no selector, calls hit your default (oldest) workspace.
# create a client workspace
curl -X POST https://linkoutreach.co/api/v1/workspaces \
-H "Authorization: Bearer sk_lis_..." -H "Content-Type: application/json" \
-d '{ "name": "Acme Inc." }'
# then work inside it (note ?workspace_id=)
curl "https://linkoutreach.co/api/v1/lists?workspace_id=<id>" \
-H "Authorization: Bearer sk_lis_..."MCP (Claude, Cursor, ChatGPT)
Add the MCP server as a connector. Sign in when prompted, then Authorize · no API key to paste. Each tool carries an optional workspace_id argument so the assistant can pick which client to act on.
Two mounts, because an assistant chooses badly out of a catalogue of a hundred and eighty tools. The live count and the whole list are on the MCP tools tab:
- ·
/mcpis the day-to-day connector: lists, sequences, inbox, imports, blocklist, settings, reporting. This is the one an account manager adds. - ·
/mcp/fulladds administration: API keys, billing, team, outbound webhooks, and connecting or disconnecting a LinkedIn account. This is the one your own automation adds.
https://linkoutreach.co/mcp https://linkoutreach.co/mcp/full
Verifying a webhook
Every delivery carries five headers.
- ·
X-Webhook-Event· the event name, e.g.message.received. - ·
X-Webhook-Delivery· the delivery id. Stable across retries. Use it as your idempotency key. - ·
X-Webhook-Attempt· attempt number, starting at 1. Two or more means we did not get a 2xx from you earlier. - ·
X-Webhook-Timestamp· unix seconds at signing time. It is part of the signed payload, so it cannot be tampered with. - ·
X-Webhook-Signature·sha256=<hex>, an HMAC-SHA256 of"<timestamp>." + <raw request body>keyed with your endpoint secret.
During a secret rotation you also receive X-Webhook-Signature-Previous, signed with the secret you are replacing. Accept either for 24 hours, then drop the old one.
The same five values also ship under X-LinkOutreach-* on every delivery, and always will. If your integration already reads those names, nothing changes and nothing needs to move. New integrations should read the neutral names above, so that nothing in your code carries our name.
Three rules. Skip any of them and the signature stops protecting you.
- Sign the raw bytes. Do not parse the JSON and re-serialise it: key order and whitespace change the bytes, and the HMAC with them.
- Reject an old timestamp. A signature proves the body came from us, not that it is arriving now. Without a tolerance window a captured request stays valid forever. Use 300 seconds.
- Compare in constant time, and store the delivery id so a retry of something you already processed is a no-op.
import hashlib
import hmac
import time
from fastapi import APIRouter, Header, HTTPException, Request
TOLERANCE_SECONDS = 300
SECRET = "whsec_..." # from GET /api/v1/webhooks
router = APIRouter()
@router.post("/hooks/linkoutreach")
async def receive(
request: Request,
x_webhook_timestamp: str = Header(...),
x_webhook_signature: str = Header(...),
x_webhook_delivery: str = Header(...),
):
raw = await request.body() # the RAW bytes, never a re-serialised dict
try:
age = abs(time.time() - int(x_webhook_timestamp))
except ValueError:
raise HTTPException(status_code=400, detail="bad timestamp")
if age > TOLERANCE_SECONDS:
raise HTTPException(status_code=400, detail="timestamp outside tolerance")
signed = x_webhook_timestamp.encode() + b"." + raw
expected = "sha256=" + hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, x_webhook_signature):
raise HTTPException(status_code=401, detail="bad signature")
if already_processed(x_webhook_delivery): # your own store
return {"ok": True}
handle(request.headers["X-Webhook-Event"], raw)
mark_processed(x_webhook_delivery)
return {"ok": True}
What we do on your side of a failure
Return any 2xx and the delivery is done. Anything else, or a timeout after 10 seconds, and we retry: three times in the first 35 seconds, then at 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, from a durable queue that survives a restart on our side. A 4xx other than 429 stops the retries immediately, because a rejected contract does not get better by being repeated.
If your endpoint fails 20 times in a row over at least 30 minutes we mute it and tell you: by email, on any other endpoint you have registered, and in GET /webhooks/{id}, which reports muted_at, muted_reason and how many deliveries are waiting. Once you are back, replay the window with POST /webhooks/{id}/replay. Every payload is stored, so a replay sends exactly what you missed, byte for byte, with a new delivery id and "replay": true in the body.
Delivery is at least once: if your 200 is lost on the wire we send again. That is why the delivery id is stable across retries · deduplicate on it.
Rate limits
Published, enforced, and returned on every response. Only requests authenticated with an sk_lis_ key are counted: browser traffic and incoming provider webhooks never are.
| Class | Per key | Per organization |
|---|---|---|
| Standard · every REST call and every MCP tool call | 120 / minute burst 40 / 10 s | 600 / minute |
| Expensive · anything that calls LinkedIn or a model | 20 / minute | 2 000 / day |
| Exempt · health, incoming webhooks, browser sessions | none | none |
The expensive class is a fixed list of operations, not a guess from the HTTP verb. GET /rate-limits returns it, along with what you have left, so you can pace a batch instead of discovering the ceiling in the middle of one.
RateLimit-Limit: 120 RateLimit-Remaining: 87 RateLimit-Reset: 34 RateLimit-Policy: 120;w=60, 20;w=60;name="expensive", 2000;w=86400;name="expensive-daily" X-RateLimit-Limit: 120 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1755590400
Both header families are sent. RateLimit-Reset is seconds remaining (the IETF convention); X-RateLimit-Reset is an epoch timestamp (the GitHub-style alias), because many HTTP clients only know that one.
Over the limit you get a 429 with Retry-After. The window it names is the one that actually blocked, so a burst refusal asks you to wait seconds, not a minute.
HTTP/1.1 429 Too Many Requests
Retry-After: 26
{
"detail": {
"code": "rate_limited",
"message": "Rate limit reached: 120 requests per 60 seconds. Retry in 26 seconds.",
"limit": 120,
"window_seconds": 60,
"scope": "api_key",
"retry_after": 26
}
}scope tells you which ceiling you hit: api_key, api_key_burst, api_key_expensive, api_key_expensive_daily or organization. If it says organization, adding another key will not help.
On MCP, a tool call counts as one request and is classified like the REST endpoint behind it. Session handshakes (initialize, tools/list) are not counted.
Counting is approximate, not a billing meter. Counters live in the serving process and are mirrored to durable storage every few seconds, so a restart can leave a few dozen requests uncounted, and a second replica would raise the effective per-minute ceiling. Need a higher limit? Ask us: it is a configuration change on your key, not a release.
API stability
/api/v1 is additive-only. We will add endpoints, optional request fields and response fields. We will never remove or rename an endpoint, an operation id, or a response field, never change a field's type, and never make an optional field required, inside v1. Breaking changes ship as /api/v2 alongside v1, announced at least 90 days ahead in your shared Slack channel, and v1 stays live for at least 12 months after v2 is generally available. Anything we do plan to remove is served with Deprecation: true and a Sunset date header for at least 6 months before it goes.
This is enforced, not promised: the list of published operation ids is frozen in the codebase, and the API refuses to start if a build removes or renames one.
Errors
One shape everywhere. code is stable and safe to branch on, message is for a human, recovery_hint tells an assistant what to do next.
{ "detail": { "code": "needs_seat", "message": "This action needs a paid seat.", "recovery_hint": "Send checkout_url to the user, then retry." } }| Code | HTTP | What it means |
|---|---|---|
| needs_account | 403 | No LinkedIn account connected. The body carries a connect_url. |
| needs_seat | 402 | Connected, but no paid seat. The body carries a checkout_url. |
| needs_pro | 402 | AI writing needs a Pro seat. |
| rate_limited | 429 | Over a published ceiling. Honour Retry-After. |
| transport_required | 422 | Give exactly one of rows, source_url or content_base64. |
| payload_too_large | 413 | Over a size ceiling. The message names the alternative. |
| blocked_url | 422 | That URL is not publicly routable, so we will not fetch it. |
| legacy_xls | 422 | Old binary .xls. Re-save as .xlsx or .csv. |
Pagination
Large collections page with an opaque cursor, never an offset: rows are inserted while you walk, and an offset silently skips them. A suppression list export that skips rows is worse than no export.
{
"entries": [ ... ],
"page": { "next_cursor": "eyJjIjoi...", "has_more": true, "limit": 100 }
}cursor="" while true; do page=$(curl -s "https://linkoutreach.co/api/v1/blocklist?limit=500&cursor=$cursor" -H "Authorization: Bearer sk_lis_...") echo "$page" | jq -r '.entries[].value' [ "$(echo "$page" | jq -r '.page.has_more')" = "true" ] || break cursor=$(echo "$page" | jq -r '.page.next_cursor') done
Default page size 100, maximum 500. Lists of leads use limit and offset with a total and a has_more, so you can always tell a 100-lead list from the first page of a 2 000-lead one.
Exports and API parity
Every field you can export is readable through this API in the same structure, because the export and the API read the same code. The dashboard is one function used by both the app and GET /dashboard; the file importer is one parser used by both the upload screen and POST /lists/import. There is no second implementation to drift.
Parity is measured rather than claimed. backend/tests/test_v1_parity.py walks three chains and fails the build if any link breaks: every path the interface calls is an enumerated route, every internal route has a v1 twin or a written exclusion, and every v1 endpoint is a tool on a mount or carries a written reason why it cannot be one. Every one of those reasons today is the same reason: MCP carries JSON, not file bytes, so the multipart uploads have no tool. Each has a JSON twin that does, and the MCP tools page names them.
Endpoints and tools
This section used to be a table of every endpoint, typed out by hand. It is gone, and what replaced it is the point: the API reference and the MCP catalogue are both computed from the running server on every request. They describe the deployment you are reading them on, and they cannot describe an older one.
The document Scalar renders is at /api/reference/openapi.json, the tool catalogue at /api/reference/mcp-tools.json. Each operation carries two extra fields you can read straight out of the JSON: x-mcp, which names the mount that exposes it as a tool, and x-auth, which says what kind of credential it needs. The full internal schema, browser routes included, stays at /api/openapi.json.