API reference
A REST API over JSON. This page is the map; the interactive reference, generated from the OpenAPI document, is live at api.getoutvoy.com/docs.
| Base URL | https://api.getoutvoy.com/v1 |
|---|---|
| OpenAPI document (live) | https://api.getoutvoy.com/v1/openapi.json |
| OpenAPI document (offline copy) | https://getoutvoy.com/openapi.json |
| Interactive reference | https://api.getoutvoy.com/docs |
| MCP server | https://api.getoutvoy.com/mcp |
The document is OpenAPI 3.1: request bodies, query parameters, error responses, an operationId per operation (createCampaign, launchCampaign…), named schemas, and every event under webhooks with its payload. The offline copy is a snapshot of the live document, for tools that can't reach the API. When they differ, the live one wins.
Authentication
Send Authorization: Bearer <token> on every request. The token is an API key (ov_…, scoped to one workspace, shown once when created; only a hash is stored) or a console session (the JWT POST /auth/sign-in returns, valid 8 hours). Revoked keys stop working at once and stay listed as revoked.
A few actions need a person, not a key: creating, listing and revoking API keys, redeeming a licence, starting a checkout, the billing portal or the extra LinkedIn accounts add-on, exporting the whole workspace and deleting it. With an API key they answer 403 SESSION_REQUIRED_ERROR, so a leaked key can't mint its own replacement. A missing or invalid token answers 401 UNAUTHORIZED, never with a WWW-Authenticate header. Sign-in and sign-up allow 20 attempts per IP every 10 minutes, then answer 429 TOO_MANY_ATTEMPTS_ERROR.
Responses
Every success is 200 OK with a JSON body { "data": … }; lists add "meta". Creating something answers 200 too, with the created object in data.
Pagination
Lists take ?cursor= and ?limit= (1–100, default 50; preview defaults to 25). Pass meta.nextCursor back as cursor for the next page; it is absent on the last page. Cursors are opaque strings.
GET /v1/leads?listId=list_xWOp0Icf&limit=2
{
"data": [
{ "id": "lead_0vnrCpdo", "firstName": "Paul", "lastName": "Murphy", "company": "Quanta Billing", ... },
{ "id": "lead_1wjEHuX9", "firstName": "Amelia", "lastName": "Brown", "company": "Initech", ... }
],
"meta": { "nextCursor": "lead_1wjEHuX908nyQUu45grRA" }
}
The events feed is a cursor feed: ?after=<event id> returns what happened since, oldest first, and meta.nextCursor is always set (an empty page means nothing new yet). Without after you get the latest page; ?before=<meta.prevCursor> reads further back. POST /v1/search/people pages with a cursor field in the body and also returns meta.total when LinkedIn reports it.
Errors
Errors use HTTP status codes and one body shape: { errorCode, message, details? }. errorCode is stable and safe to branch on; message is for people; details carries structure when there is some, such as the issues that block a launch.
HTTP/1.1 422 Unprocessable Entity
{
"errorCode": "CAMPAIGN_LAUNCH_BLOCKED_ERROR",
"message": "The campaign can't launch: The campaign has no senders: add at least one to senderIds; The sequence has no steps; ...",
"details": {
"issues": [
{ "code": "no_senders", "blocking": true, "message": "The campaign has no senders: add at least one to senderIds" },
{ "code": "no_steps", "blocking": true, "message": "The sequence has no steps" },
{ "code": "no_leads", "blocking": true, "message": "No leads: add leads to its lists or straight to the campaign (POST /v1/campaigns/{id}/leads)" }
]
}
}
| Status | Meaning | Typical errorCode |
|---|---|---|
| 401 | Missing, invalid or revoked token, or wrong email and password. | UNAUTHORIZED, INVALID_CREDENTIALS_ERROR |
| 402 | Your plan doesn't allow more (senders, live campaigns), or the sender was paused by a plan change and the plan still has no room for it. The message names the plan that does. | PLAN_LIMIT_REACHED_ERROR |
| 402 | The workspace has no plan yet: senders, sandbox senders, imports and launches wait for the 7-day trial (Settings → Billing in the console) or a licence. The message says so. | PLAN_REQUIRED_ERROR |
| 403 | The action needs a console session, not an API key. | SESSION_REQUIRED_ERROR |
| 404 | Not found in this workspace. | ENTITY_NOT_FOUND |
| 409 | Conflicts with the current state: launching a running campaign, editing steps while it runs, pausing a sender that isn't active. | CAMPAIGN_STATUS_CONFLICT_ERROR, CAMPAIGN_NOT_EDITABLE_ERROR, SENDER_NOT_ACTIVE_ERROR |
| 422 | The body or query failed validation (unknown fields included), an Idempotency-Key was reused with another body, or a domain rule refuses it. | UNPROCESSABLE_CONTENT, IDEMPOTENCY_KEY_REUSED_ERROR, INVALID_CAMPAIGN_SEQUENCE_ERROR, CAMPAIGN_LAUNCH_BLOCKED_ERROR |
| 429 | Too many sign-in or sign-up attempts from one IP (20 per 10 minutes), or the sender's daily message cap is used up (replying in the inbox). | TOO_MANY_ATTEMPTS_ERROR, SENDER_DAILY_MESSAGE_CAP_REACHED_ERROR |
| 502 / 503 | LinkedIn failed the request, or real LinkedIn senders aren't available on this server. | LINKEDIN_REQUEST_FAILED_ERROR, LINKEDIN_NOT_CONFIGURED_ERROR |
Validation failures answer 422 UNPROCESSABLE_CONTENT: message names the first problem in a sentence, and details.issues lists every one as { path, message, code }. Every body, query string and MCP argument object is strict: a field the API doesn't know is refused, never silently dropped, so a typo in an optional field fails loudly instead of changing what runs.
POST /v1/campaigns
{ "name": "Bad", "steps": [{ "type": "invite", "after": "1d" }] }
HTTP/1.1 422 Unprocessable Entity
{
"errorCode": "UNPROCESSABLE_CONTENT",
"message": "steps.0.after: Unknown field \"after\"",
"details": {
"issues": [
{ "path": "steps.0.after", "message": "Unknown field \"after\"", "code": "unrecognized_keys" }
]
}
}
Idempotency
Every write (POST, PATCH, DELETE) accepts an Idempotency-Key header (at most 200 characters). The first successful answer for a key is stored for 24 hours, per caller, method and path, together with a hash of the body, and replayed for every retry with the same key and body, with an Idempotent-Replayed: true header. The same key with a different body is refused with 422 IDEMPOTENCY_KEY_REUSED_ERROR, so use a value that is unique per intended object, such as campaign-revops-de. Failed answers aren't stored: retrying after an error runs the request again.
Ids and times
Ids are prefixed strings, so you can tell what an id points to: org_ workspace, user_ user, key_ API key, snd_ sender, list_ list, lead_ lead, imp_ import, blk_ blocklist entry, cmp_ campaign, enr_ enrollment, act_ action, cnv_ conversation, msg_ message, evt_ event, whk_ webhook endpoint, whd_ webhook delivery, exp_ workspace export. Times are ISO 8601 in UTC (2026-10-28T12:51:07.282Z).
Endpoints
Paths are relative to https://api.getoutvoy.com. Every /v1 route accepts an API key or a session unless it says session only.
Account and workspace
| Endpoint | What it does |
|---|---|
| POST /auth/sign-up | Create a user and its workspace (no plan until the 7-day trial starts); returns a session token. No auth. |
| POST /auth/sign-in | Email and password in, a session token (8 hours) out. No auth. |
| GET /v1/me | The workspace with its plan, limits and usage (senders, enrollments this month, live campaigns, lists, launched campaigns, unread conversations), and the user or API key calling. |
| PATCH /v1/workspace | Rename the workspace. |
| GET /v1/api-keys | List API keys. Session only. |
| POST /v1/api-keys | Create an API key; the secret is in this response only. Session only. |
| DELETE /v1/api-keys/{apiKeyId} | Revoke an API key. Session only. |
| GET /v1/licenses | The workspace's AppSumo licence, if any. |
| POST /v1/licenses/redeem | Attach an AppSumo licence from its one-time token; a monthly subscription is set to end. Session only. |
| GET /v1/billing | The plan, where it comes from (subscription, license, none), trial end, next charge and price, and extraSenders: the extra LinkedIn accounts add-on. |
| POST /v1/billing/checkout | A Stripe Checkout URL for starter, growth or scale: the 7-day trial, card required. Session only. |
| POST /v1/billing/portal | A Stripe customer portal URL: payment method, invoices, plan changes, cancellation. Session only. |
| POST /v1/billing/extra-senders | { quantity } extra LinkedIn accounts on an AppSumo licence (1 is included; each extra one is a monthly add-on): a Checkout URL the first time, a prorated change after, 0 ends it with its paid period. Never below the LinkedIn senders connected beyond the included one. Session only. |
Your data: export and deletion
The whole workspace leaves in one ZIP: a JSON file per kind of record (leads, lists and memberships, campaigns with their steps, enrollments, every action the engine took, conversations and messages, senders, blocklist, events, webhook endpoints, imports, API key names, billing, users), CSV copies of leads, lists and memberships, and a README.json that describes every file. Secrets are never in it: no password or key hashes, no webhook signing secrets, no LinkedIn sessions. Exporting everything and deleting the workspace are for workspace owners in a console session; agents read each resource through its own endpoint. To erase one contacted person, use DELETE /v1/leads/{leadId} (keys work).
| Endpoint | What it does |
|---|---|
| POST /v1/exports | Queue a full export; it is usually ready within a minute. One at a time (409 DATA_EXPORT_IN_PROGRESS_ERROR). Session only. |
| GET /v1/exports | The last 10 exports; ready ones carry a downloadUrl. Session only. |
| GET /v1/exports/{exportId} | Its status (queued, running, ready, failed, expired) and, once ready, a signed downloadUrl valid for 15 minutes. Archives are deleted after 7 days. Session only. |
| GET /exports/{exportId}/download?expires=&signature= | The ZIP itself. No auth header: the signed link is the credential, so a browser can open it. |
| GET /v1/lists/{listId}/export | One list's leads as CSV, with addedAt and a fields.<name> column per custom field. Keys work here. |
| DELETE /v1/workspace | Delete the workspace and everything in it, with your account, confirmed by confirmName (its exact name). LinkedIn accounts are disconnected at the provider, a monthly subscription is set to end (no further charge) and an AppSumo licence is released first; if a provider refuses, nothing is deleted (502 WORKSPACE_DELETION_BLOCKED_ERROR). Session only. |
Senders
| Endpoint | What it does |
|---|---|
| GET /v1/senders | Every sender except disconnected ones, with limits, effective limits (warm-up applied), schedule and today's usage. |
| POST /v1/senders/connect-link | A hosted page URL where the account owner signs in to LinkedIn. Pass reconnectSenderId to repair a sender. |
| POST /v1/senders/sandbox | Create a simulated sender, active at once. |
| GET /v1/senders/{senderId} | One sender. |
| PATCH /v1/senders/{senderId} | Change name, limits (any subset), schedule (replaced whole) or warmupEnabled. |
| DELETE /v1/senders/{senderId} | Disconnect: it stops for good and frees its plan slot; the record stays for history. A LinkedIn sender's session is removed from our provider (retried in the background if the provider is down). |
| POST /v1/senders/{senderId}/pause | Stop an active sender in every campaign. |
| POST /v1/senders/{senderId}/resume | Resume a paused sender. |
Search
| Endpoint | What it does |
|---|---|
| POST /v1/search/people | One page of LinkedIn people through a sender (filters or a pasted url). Nothing is saved. |
| GET /v1/search/parameters?senderId=&type=&q= | Ids for search filters: type is LOCATION, INDUSTRY, COMPANY or SCHOOL. |
Lists, leads and imports
| Endpoint | What it does |
|---|---|
| GET /v1/lists | Every list with its lead count. |
| POST /v1/lists | Create a list (name, optional description). |
| GET /v1/lists/{listId} | One list. |
| PATCH /v1/lists/{listId} | Rename or describe a list. |
| DELETE /v1/lists/{listId} | Delete a list and its imports; its leads stay in the workspace. |
| GET /v1/lists/{listId}/leads | The list's leads. |
| GET /v1/lists/{listId}/export | The list's leads as a CSV file. |
| POST /v1/lists/{listId}/leads | Add up to 1,000 people by profile URL, each with optional names, company, title and fields. |
| DELETE /v1/lists/{listId}/leads | Remove leads from a list by leadIds. |
| POST /v1/lists/{listId}/imports | Start a background import from a people search (filters or url) or a post's engagers. |
| GET /v1/lists/{listId}/imports | The list's imports. |
| GET /v1/imports/{importId} | Import progress: status, found, imported, skipped, error. |
| GET /v1/leads?listId=&search= | The workspace's leads, optionally one list's or matching a search. |
| GET /v1/leads/{leadId} | One lead with its profile data and fields. |
| PATCH /v1/leads/{leadId} | Merge custom fields into a lead ("" removes a key). |
| DELETE /v1/leads/{leadId} | Erase a person who asked to be forgotten: their live enrollments stop first, then the lead, its memberships, enrollments, actions, conversations and messages are deleted, and past events keep their ids but lose the person's details (lead.redacted: true). Optional body { "blocklist": false }; by default their profile joins the blocklist. Emits lead.deleted. Keys work here. |
Blocklist
| Endpoint | What it does |
|---|---|
| GET /v1/blocklist | People and companies kept out of every campaign. |
| POST /v1/blocklist | Block up to 1,000 entries: { kind: "profile", value: <profile URL> } or { kind: "company", value: <company name> }. |
| DELETE /v1/blocklist/{entryId} | Remove one entry. |
Campaigns and enrollments
| Endpoint | What it does |
|---|---|
| GET /v1/campaigns?status= | Campaigns newest first with their funnel; archived ones only with ?status=archived. |
| POST /v1/campaigns | Create a draft: name, senderIds, listIds, steps, settings. The sequence is validated now. |
| GET /v1/campaigns/{campaignId} | One campaign. |
| PATCH /v1/campaigns/{campaignId} | Rename at any time; change senders, lists, steps or settings only while draft or paused. |
| DELETE /v1/campaigns/{campaignId} | Delete a draft; any other campaign is archived instead. |
| POST /v1/campaigns/{campaignId}/preview | Every lead with its sender, every rendered note and message, and every issue. Saves and sends nothing. |
| POST /v1/campaigns/{campaignId}/launch | Enroll every eligible lead and start. Refused with 422 while a blocking issue remains. |
| POST /v1/campaigns/{campaignId}/pause | Stop sending; enrollments keep their place. |
| POST /v1/campaigns/{campaignId}/resume | Resume a paused campaign. |
| POST /v1/campaigns/{campaignId}/archive | Final: live enrollments stop with campaign_archived. |
| POST /v1/campaigns/{campaignId}/leads | Add up to 100 leads straight to the campaign, by leadId or profileUrl, each with its own fields; one result per lead, in input order. |
| GET /v1/campaigns/{campaignId}/stats | The funnel in total, per step and per sender. |
| GET /v1/campaigns/{campaignId}/enrollments?status= | Every lead in the campaign with its status, waitingReason (live ones), stopDetail (finished ones) and conversationId. |
| GET /v1/campaigns/{campaignId}/enrollments/{enrollmentId} | One enrollment with every action planned or done. |
| PATCH /v1/campaigns/{campaignId}/enrollments/{enrollmentId} | Merge enrollment fields (they override the lead's in this campaign). |
| POST /v1/campaigns/{campaignId}/enrollments/{enrollmentId}/stop | Stop one live lead's sequence (stopped_by_user). |
Inbox
| Endpoint | What it does |
|---|---|
| GET /v1/conversations?senderId=&leadId=&unread= | Conversations, newest activity first, with the last message, the lead, the sender and the campaign. |
| GET /v1/conversations/{conversationId} | Every message, oldest first. Marks the conversation read. |
| POST /v1/conversations/{conversationId}/messages | Reply as plain text from the conversation's sender; counts against its daily message cap. |
Events and webhooks
| Endpoint | What it does |
|---|---|
| GET /v1/events?after=&before=&types=&limit= | The event feed. types is comma-separated, e.g. lead.replied,lead.invite_accepted. |
| GET /v1/webhooks | Webhook endpoints (at most 20 per workspace). |
| POST /v1/webhooks | Create an endpoint: url, optional description and eventTypes (empty means all). Returns the signing secret once. |
| GET /v1/webhooks/{webhookEndpointId} | One endpoint. |
| PATCH /v1/webhooks/{webhookEndpointId} | Change URL, description or event types, or enabled (re-enabling resets the failure streak). |
| DELETE /v1/webhooks/{webhookEndpointId} | Delete an endpoint with its delivery log and pending retries. |
| POST /v1/webhooks/{webhookEndpointId}/rotate-secret | A new secret signs from now on; the old one stops at once. |
| GET /v1/webhooks/{webhookEndpointId}/deliveries | Every attempt, newest first, with status, response code and the next retry. |
| POST /v1/webhooks/{webhookEndpointId}/test | Send a signed webhook.test ping now and return the attempt. |
Outside /v1
| Endpoint | What it does |
|---|---|
| POST /mcp | The MCP server (API key only). See MCP. |
| GET /v1/openapi.json | The OpenAPI document. No auth. |
| GET /docs | The interactive reference. No auth. |