# HTTP API

Base URL: `https://undocrm.com/v1`. JSON in, JSON out. Auth: `Authorization: Bearer <key>`.

The agent endpoints have the same semantics as the [MCP tools](/docs/mcp-tools), but return structured JSON — use them from backends, queues and agent frameworks without MCP.

Errors look like:

```json
{ "error": { "code": "invalid_input", "message": "Could not read \"@jane\" as a person. …" } }
```

| Status | Code |
| --- | --- |
| 400 | `invalid_input` |
| 401 | `unauthorized` — missing, invalid or revoked key |
| 403 | `forbidden` — e.g. an agent key on an owner endpoint |
| 404 | `not_found` |
| 409 | `conflict` — e.g. undoing something twice |

## Pagination

Lists page with keyset cursors, so every page is as fast as the first, even at millions of rows. Pass `limit` (default 50, max 200); the response includes `next_cursor`, which you send back unchanged as `?cursor=` for the next page. `next_cursor: null` means you've reached the end.

```bash
curl "https://undocrm.com/v1/contacts?limit=100" -H "Authorization: Bearer $KEY"
# → { "contacts": [...], "next_cursor": "eyJ0IjoiMjAyNi0xMC0xNyAwMjo1NDozMi44NjQ3MjMrMDAiLCJpZCI6ImN0X..." }
curl "https://undocrm.com/v1/contacts?limit=100&cursor=eyJ0Ijoi..." -H "Authorization: Bearer $KEY"
```

| List | Sorted by | Extra filters |
| --- | --- | --- |
| `GET /contacts` | last activity, newest first | `lifecyclestage` (or `none`) |
| `GET /contacts?q=` | relevance (cursor is an offset; results are capped at a few hundred) | |
| `GET /follow-ups` | due date, soonest first | `due`, `mine`, `person` |
| `GET /deals/column` | last updated, newest first | `pipeline`, `dealstage` (required) |
| `GET /contacts/:id/timeline` | newest first: interactions older than the cursor from `GET /contacts/:id`, with the events in the same window | |
| `GET /audit` | newest first; pages with `before=<id>` | `contact_id`, `agent_id` |

Totals for headers come from cheap count endpoints: `GET /contacts/groups` (contacts per lifecycle stage), `GET /follow-ups/counts` (overdue / today / upcoming), and the `totals` in `GET /deals/board`.

## Authentication

| Caller | Header | Reaches |
| --- | --- | --- |
| Agent | `Authorization: Bearer ucrm_agt_…` | Workspace endpoints (as that agent), MCP |
| Backend, one workspace | `Authorization: Bearer ucrm_own_…` | Workspace endpoints, including administration |
| Backend, organization | `Authorization: Bearer ucrm_org_…` | `POST/GET /workspaces` |
| Signed-in person | `Authorization: Bearer <session token>` and `X-Workspace-Id: ws_…` | Account endpoints; workspace endpoints for workspaces in their organizations, by role |

## Workspaces for your customers — org key

### `POST /workspaces`

```bash
curl -X POST https://undocrm.com/v1/workspaces \
  -H "Authorization: Bearer $ORG_KEY" -H "content-type: application/json" \
  -d '{"name": "Acme Recruiting"}'
```

```json
{ "workspace": { "id": "ws_cd7b82dcfdcc4ed0", "name": "Acme Recruiting", "org_id": "org_…", "created_at": "…" },
  "owner_key": "ucrm_own_…" }
```

The owner key is shown once; use it to create that workspace's agents. `GET /workspaces` lists your organization's workspaces.

## Accounts — signed-in people

These power the web app. All take a signed-in session token; none take API keys.

| Method | Path | Body | |
| --- | --- | --- | --- |
| GET | `/me` | | You, your organizations (with role and workspaces), invites waiting for you |
| PATCH | `/me` | `{name}` | |
| POST | `/orgs` | `{name, workspace_name?}` | New organization with a first workspace; you're its owner |
| PATCH | `/orgs/:org` | `{name}` | Admin |
| GET | `/orgs/:org/members` | | Members and pending invites |
| PATCH | `/orgs/:org/members/:user` | `{role}` | Admin; only owners change owners |
| DELETE | `/orgs/:org/members/:user` | | Admin, or yourself to leave. The last owner can't leave |
| POST | `/orgs/:org/invites` | `{email, role}` | Admin. Returns `{url, emailed}` |
| DELETE | `/orgs/:org/invites/:id` | | Admin |
| GET | `/invites/:token` | | Public preview: organization, inviter, masked email |
| POST | `/invites/:token/accept` | | Must be signed in as the invited email |
| GET/POST | `/orgs/:org/workspaces` | `{name}` | List; create (admin) |
| PATCH | `/orgs/:org/workspaces/:ws` | `{name}` | Admin |
| GET/POST/DELETE | `/orgs/:org/keys[/:id]` | `{label}` | Org API keys (admin) |
| GET | `/orgs/:org/audit` | `?before` | Organization-level changes (admin) |

## Workspace administration — owner key or admin

| Method | Path | Body | |
| --- | --- | --- | --- |
| GET | `/workspace` | | Name, your role, counts. Works with any key. |
| GET | `/agents` | | Agents with key prefixes, last use, activity |
| POST | `/agents` | `{name, description?}` | Returns `{agent, api_key}`; key shown once |
| POST | `/agents/:id/keys` | | Another key for an agent (rotation) |
| POST | `/owner-keys` | `{label?}` | Another owner key |
| DELETE | `/keys/:id` | | Revoke; effective within 30s |
| GET | `/audit` | `?limit&before&contact_id&agent_id` | Newest first; page with `before=<id>` |
| POST | `/audit/:id/undo` | | Reverse one action |
| DELETE | `/contacts/:id/do-not-contact/:blockId` | | Lift a block (humans only) |

## People — any key

| Method | Path | Body / query | |
| --- | --- | --- | --- |
| GET | `/contacts` | `?q&limit` | Search; without `q`, most recently active |
| POST | `/contacts` | `{person?, identities?, name?, title?, company?, facts?}` | Create/update/merge. 201 if created |
| GET | `/contacts/:person` | | Full record: identities, interactions, facts (with history), follow-ups, blocks, claim, audit |
| POST | `/contacts/:person/do-not-contact` | `{reason, channel?}` | |

`:person` accepts any person string, URL-encoded (`/contacts/jane%40acme.com`).

## Deals and pipelines — any key

| Method | Path | Body / query | |
| --- | --- | --- | --- |
| GET | `/pipelines` | | Pipelines with ordered stages (`key`, `label`, `probability`, `is_closed`) |
| POST | `/pipelines` | `{label, stages: ["Applied", …]}` | |
| GET | `/deals` | `?pipeline&dealstage&person&open=true` | |
| POST | `/deals` | `{person?, dealname, pipeline?, dealstage?, amount?, closedate?, currency?, company?}` | |
| GET | `/deals/:id` | | |
| PATCH | `/deals/:id` | any of the above | Stage key or label |
| DELETE | `/deals/:id` | | Archive (undoable) |
| GET | `/properties` | | The property registry, with usage counts |
| GET | `/interactions/search` | `?q=` | Full-text search over every conversation |
| GET | `/overview` | | Owner key: 14-day activity, agents, pipeline funnel (powers the web home page) |

## Agent operations — agent key (owner keys can read)

### `GET /brief?person=&purpose=&max_tokens=`

```json
{ "text": "# Jane Doe — VP Engineering, Acme …", "contact_id": "ct_…", "tokens": 172,
  "omitted": { "facts": 0, "interactions": 0, "follow_ups": 0 } }
```

### `POST /can-contact`

```json
{ "person": "jane@acme.com", "channel": "email", "purpose": "send SOC2 report" }
```

```json
{ "allowed": true, "reason": "ok", "contact_id": "ct_…", "channel": "email", "block": null,
  "claim": { "id": "cl_…", "agent_id": "ag_…", "agent_name": "sdr-1", "purpose": "send SOC2 report", "expires_at": "…" },
  "last_outbound": { "channel": "email", "occurred_at": "…", "agent_name": "sdr-1" } }
```

`reason` is one of `ok`, `do_not_contact`, `claimed_by_other_agent`.

### `POST /release`

`{ "person": "jane@acme.com" }` → `{ "contact_id": "ct_…", "released": true }`

### `POST /interactions`

Body as in [`log_interaction`](/docs/mcp-tools), plus optional `source` (default `agent`; sync integrations set their own, e.g. `gmail`). Returns 201:

```json
{ "duplicate": false, "interaction_id": "ix_…", "contact_id": "ct_…", "created_contact": false,
  "facts": [{ "id": "fa_…", "key": "budget", "value": "$80k/yr", "previous": "$50k/yr" }],
  "follow_ups": [{ "id": "fu_…", "action": "Send the SOC2 report", "due_at": "…" }],
  "completed_follow_ups": [], "warning": null }
```

A repeated `external_id` (per `source`) returns `{ "duplicate": true, … }` with 200.

### `GET /follow-ups?due=&mine=&person=&limit=`

`due`: `overdue`, `today` (default), `week`, `all`. `mine` defaults to `true` for agent keys.

### `POST /follow-ups/:id/complete`

`{ "status": "done" | "cancelled", "outcome": "…" }`
