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, but return structured JSON — use them from backends, queues and agent frameworks without MCP.

Errors look like:

{ "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.

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

curl -X POST https://undocrm.com/v1/workspaces \
  -H "Authorization: Bearer $ORG_KEY" -H "content-type: application/json" \
  -d '{"name": "Acme Recruiting"}'
{ "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=

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

POST /can-contact

{ "person": "jane@acme.com", "channel": "email", "purpose": "send SOC2 report" }
{ "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, plus optional source (default agent; sync integrations set their own, e.g. gmail). Returns 201:

{ "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": "…" }