# Public REST API (v1)

Workspace-scoped HTTP API authenticated with an `X-ApiKey` header.

**AI agents:** [Installation & agent skill](./agents/README.md)  |  [Use cases](./agents/use-cases.md)

## Authentication

```
X-ApiKey: ufa_...
```

Keys are created in **Settings -> Integrations**. The full secret is shown **once** on create or roll -- it cannot be retrieved later (roll-to-reveal).

Invalid or missing keys receive `401` with JSON:

```json
{ "error": "Invalid or missing API key." }
```

## Semantics

| Route | Method | Semantics |
| --- | --- | --- |
| `/api/v1` | GET | Read-only -- API metadata |
| `/api/v1/workspace` | GET | Read-only -- workspace summary (no credentials) |
| `/api/v1/prospects` | GET | Read-only -- list prospects (cap 500) |
| `/api/v1/prospects/{id}` | GET | Read-only -- single prospect |
| `/api/v1/tags` | GET | Read-only -- list tags |
| `/api/v1/sequences` | GET | Read-only -- list sequences + steps |
| `/api/v1/templates` | GET | Read-only -- list templates |
| `/api/v1/prospects` | POST | **Append-only** -- import prospects (dedupes on LinkedIn URL) |
| `/api/v1/tags` | POST | Append -- create tag if missing |
| `/api/v1/tags/{id}/prospects` | PUT | Append-only -- attach tag to prospects (JSON **array** of ids) |
| `/api/v1/sequences/{id}/prospects` | PUT | Append-only -- enroll prospects (JSON **array** of ids; skips already enrolled) |
| `/api/v1/channels` | GET | Read-only -- list connected channels |
| `/api/v1/search` | POST | Persists nothing, but **rate limited like a write** -- one-shot LinkedIn/Sales Navigator people search (no persisted search object) |
| `/api/v1/prospects/{id}/connect` | POST | Write -- send (or stage, under HITL) a LinkedIn connection request |
| `/api/v1/prospects/{id}/message` | POST | Write -- send (or stage, under HITL) a LinkedIn InMail |
| `/api/v1/suppressions` | GET | Read-only -- list do-not-contact suppressions |
| `/api/v1/suppressions` | POST | Write -- add a do-not-contact suppression |
| `/api/v1/suppressions/{id}` | DELETE | Write -- remove a do-not-contact suppression |
| `/api/v1/enrollments/{id}/cancel` | POST | Write -- stop outreach already running for this enrollment |
| `/api/v1/enrollments/{id}/resume` | POST | Write -- restart outreach paused by a reply |
| `/api/v1/approvals` | GET | Read-only -- list everything staged pending approval (oldest first, `limit` default 100 / max 500) |
| `/api/v1/approvals/{id}/approve` | POST | Write -- approve a staged item (not synchronous -- see below) |
| `/api/v1/approvals/{id}/complete` | POST | Write -- mark a staged item done without sending |
| `/api/v1/approvals/{id}/skip` | POST | **Removed** (410) -- use complete or enrollment pause/resume |

### Write behaviour

- **Unknown JSON fields on write are ignored** -- only documented fields are read; extras are not rejected.
- **Invalid enums** return a validation body: `{ "error": "...", "fields": { "field": "Must be one of: ..." } }`.
- **Non-array body where an array is expected** returns `400` with `"Expected prospects to be a JSON array."`
- **`source` on `POST /api/v1/prospects`** is a free-text label (default `api`), except `linkedin_search`, which is reserved for Autopilot's own search: a batch with it returns `400`. Those rows join the pool Autopilot enrolls without a human yes.
- **Write throttling**: more than 60 writes/minute/key -> `429` with `Retry-After` (seconds). `POST /api/v1/search` counts against this budget too -- it persists nothing, but every call spends a paid provider request.
- **Unknown `/api/*` paths** return JSON `404`, never the HTML app shell.

### Deep queues

`PUT /api/v1/sequences/{id}/prospects` accepts the enroll and then, when the
sequence sends connection requests, reports how long its queue has become:

```json
{ "ok": true, "enrolled": ["pro_1"], "skipped": [],
  "warning": { "reason": "connect_runway", "days": 402, "message": "About 402 days ..." } }
```

Nothing is dropped -- people past the daily sending rate wait their turn rather
than expiring -- but a queue measured in months is usually not what the caller
meant. `warning` is `null` when the queue is short or the sequence sends no
connection requests, and absent entirely when nothing was enrolled.

### Connection fit

A sequence that opens with a connection request is for people you are not
connected to; one that opens with a LinkedIn message is for people you are.
People who do not fit are left out, not started, and come back in `skipped`:

```json
{ "ok": true, "enrolled": ["pro_1"],
  "skipped": [{ "prospectId": "pro_2", "reason": "already_connected" }] }
```

- `already_connected` -- the sequence opens with a connection request.
- `not_connected` -- the sequence opens with a LinkedIn message and they are
  not connected (an invite pending counts as not connected). People whose
  connection is not known yet are started; the message waits until it is.

Start the skipped people on a sequence that fits them with a second call.

### Import dedupe

`POST /api/v1/prospects` skips rows whose normalized LinkedIn `/in/` URL already exists in the workspace. Suppressed profiles are skipped silently.

### Result caps

List endpoints default to `limit=100`, maximum `500`. Use `offset` for pagination.

## Search and outreach

`POST /api/v1/search` runs a one-shot LinkedIn/Sales Navigator people search
against a connected channel (`GET /api/v1/channels` to find one) and returns
candidates with a `cursor` for pagination -- no persisted search object is
created. Import the candidates you want via `POST /api/v1/prospects`, then
send with `POST /api/v1/prospects/{id}/connect` or `.../message`.

Search `400`s (rather than returning a raw provider failure) when live
messaging is off for the deployment (demo mode, or missing provider
credentials), when `channelId` is not a connected channel in your workspace,
when `api: "sales_navigator"` is used on a seat without the Sales Navigator
entitlement, or when a Sales Navigator filter value is invalid.

### Send failure reasons

`connect` and `message` return `{ "error": "...", "reason": "..." }`. Branch on
`reason`, not on the message:

| `reason` | Meaning |
| --- | --- |
| `not_found` (404) | No such prospect in this workspace |
| `no_linkedin_url` | The prospect has no LinkedIn URL |
| `note_too_long` | `connect` only -- the note exceeds the provider limit |
| `empty_body` | `message` only -- `body` was blank |
| `suppressed` | The prospect matches a do-not-contact rule -- nothing was sent **or** staged |
| `kill_switch` | Sending is paused for the workspace or channel |
| `daily_limit` / `weekly_limit` | Quota for this channel and action is spent |
| `already_connected` / `already_pending` | `connect` only -- connection state makes the send a no-op |
| `no_channel` | No connected LinkedIn channel is available |
| `channel_not_found` | The `channelId` you supplied is not a connected channel here -- the send is refused rather than quietly going out from a different seat |
| `provider_error` | The provider rejected or could not perform the send |

The do-not-contact list (`/api/v1/suppressions`) is enforced on these one-off
sends exactly as it is on sequenced sends: a suppressed prospect can never be
reached through the API or the app's own "Send connection" button.

Both send routes fail fast on quota, kill-switch, or an already-
connected/pending status -- they do not queue. If the workspace has HITL
(`workspace.hitl`) on, they stage instead of sending and return
`{ "status": "pending_approval", "actionItemId": "..." }`; clear it with
`POST /api/v1/approvals/{id}/approve`, which unblocks the item for the next
send tick (approving is not itself synchronous).

`.../message` sends InMail via the provider regardless of the prospect's
connection status -- it doesn't require a prior connection. Both routes also
respect the workspace kill switch, so a halted workspace fails the same way
whether the call comes from the API or the UI's "Send connection" button.

Staged one-off approvals from HITL are **not idempotent**: retrying a POST to
`connect` or `message` while HITL is on creates a second pending-approval
item rather than returning the existing one. Don't blindly retry these calls
-- check `GET /api/v1/approvals` first if a request may have already landed.

## Managing a book

- `POST /api/v1/suppressions` / `DELETE /api/v1/suppressions/{id}` -- do-not-contact.
- `POST /api/v1/enrollments/{id}/cancel` / `.../resume` -- stop or restart outreach already running.
- `GET /api/v1/approvals`, `POST /api/v1/approvals/{id}/approve|skip` -- clear anything staged (including from HITL above). The list is oldest-first and capped (`limit`, default 100, max 500); each row carries `expiresAt` (epoch ms, or `null`) so you can clear what is closest to ageing out first.

## Example

```bash
curl -s -H "X-ApiKey: $KEY" "https://app.example.com/api/v1/prospects?limit=10"
```

```bash
curl -s -X POST -H "X-ApiKey: $KEY" -H "Content-Type: application/json" \
  -d '[{"name":"Ada Lovelace","linkedinUrl":"https://www.linkedin.com/in/ada/"}]' \
  "https://app.example.com/api/v1/prospects"
```

## Never returned

Responses must not include API key material, membership/login identifiers used as credentials, or raw provider profile blobs.
