REST paths
Every path below is a different URL for a call the API already had. The request is answered by the
same dispatcher that answers POST /mcp/query, POST /mcp/mutate and POST /mcp/run, with the
same authentication, the same scope check, the same organisation scoping and the same refusals. A
path cannot reach anything the RPC form refuses, because it is the RPC form underneath.
https://api.selda.ai/v1/...
Authorization: Bearer sk_live_...The OpenAPI document
openapi.json is OpenAPI 3.1, generated from the same
registry. Import it into Postman, or point a client generator at it.
value is published
for 1 of the 71 operations, because one is derived only where the target function
declares a returns validator. Everywhere else value carries a description saying exactly that,
rather than an invented object. A generated client that enforces a shape nobody derived is worse
than one that enforces nothing. Call an operation once with a test key and read what comes
back.How a name becomes a path
The rules are mechanical, so a new registry entry gets its path without anybody choosing one.
- The method comes from the endpoint the dispatcher already routes the function to, not from
its name. Everything on
/mcp/queryruns a Convex query, which structurally cannot write, soGETis safe by construction. Everything else changes something or spends something and is never aGET. - An identifier reaches the path only when it is the group’s own.
leads.gettakes aleadId, so it isGET /v1/leads/{leadId}.leads.listtakes a requiredprojectId, which is a filter and not the address of a lead, so it stays a query parameter and the path isGET /v1/leads. - The name segment is dropped only where the method already says it:
listandgetunderGET,addandcreateunderPOST. Everything else keeps its verb. - An action keeps its verb.
runs.confirmCompaniesisPOST /v1/runs/{runId}/confirm-companies. It is a thing you do to a run, not a field of one, and dressing it up as aPATCHof some invented status would read as idempotent while it starts paid work. GETcarries its arguments in the query string and everything else takes a JSON body. Repeat a parameter for a list:?tags=a&tags=b.
Every path
71 routes across 18 groups, plus the upload door.
brain
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/brain | brain.list | The workspace’s structured knowledge: products, partners, references, company facts, and the things Selda must never say. Each item has a type, a title and a body. |
POST | /v1/brain | brain.add | Add one thing Selda should know: a product, a partner, a reference, a company fact, a note, something it must never say, or a writing_rule — a standing instruction about HOW messages are written, which reaches the composer as a directive and is never quoted as material. |
POST | /v1/brain/remove | brain.remove | Take one Brain item back out. The human owns what Selda knows. |
POST | /v1/brain/update | brain.update | Rewrite the title and body of one Brain item. |
campaigns
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/campaigns | campaigns.list | Campaigns in a workspace. |
POST | /v1/campaigns | campaigns.create | Create a campaign (legacy table, not the one the app’s campaign-flow UI reads). |
GET | /v1/campaigns/{campaignId} | campaigns.get | One campaign: status, channels, settings, leads. |
PATCH | /v1/campaigns/{campaignId} | campaigns.update | Change a campaign. |
POST | /v1/campaigns/{campaignId}/add-leads | campaigns.addLeads | Put specific leads into a campaign. |
POST | /v1/campaigns/{campaignId}/lock-message-structure | campaigns.lockMessageStructure | Lock a campaign’s message structure so every locked block ships exactly as written and nothing rewrites it, or unlock it with locked: false. Sends nothing. |
GET | /v1/campaigns/{campaignId}/message-structure | campaigns.messageStructure | Read what a campaign’s message is made of: every block, which ones ship verbatim, the instruction behind each generated one, the shape, and whether it is locked. |
POST | /v1/campaigns/{campaignId}/set-message-structure | campaigns.setMessageStructure | State what a campaign’s message is made of: blocks that ship WORD FOR WORD, blocks Selda writes from an instruction you give it, the paragraph count, and what must never appear. Refuses to change a locked structure. Sends nothing. |
GET | /v1/campaigns/{campaignId}/stats | campaigns.stats | Campaign counters: sent, delivered, opened, clicked, replied, bounced. |
POST | /v1/campaigns/add-leads-by-tag | campaigns.addLeadsByTag | Put every lead carrying a tag into a campaign (legacy table). |
POST | /v1/campaigns/add-rule | campaigns.addRule | Add a campaign rule. |
company
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/company/lookup | company.lookup | Resolve a company and return the right people to reach. Starts nothing. (live key only) |
connectors
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/connectors | connectors.list | Data connectors registered for this workspace. |
POST | /v1/connectors | connectors.create | Register a data connector. |
DELETE | /v1/connectors/{connectorId} | connectors.delete | Remove a data connector. |
POST | /v1/connectors/{connectorId}/sync | connectors.sync | Pull from a connected data source. (live key only) |
credits
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/credits/info | credits.info | Credit balance, daily free credits, usage, plan. |
drafts
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/drafts/remove | drafts.remove | Take one draft out of a run so it cannot be sent. The row stays visible with your reason and the app can put it back. Refuses a message that already went out. |
POST | /v1/drafts/update | drafts.update | Rewrite the draft on one run lead. Refuses a message that already went out; never sends. |
engine
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/engine/start | engine.start | The full pipeline from a brief: find companies → research → fit → hook → draft. It STOPS at the company list (run status awaiting_profile) and waits for a person to confirm the companies and the decision-maker roles in the Selda app. Poll runs.status and read awaitingHuman. Nothing is ever sent from here. (live key only) |
events
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/events/ingest | events.ingest | Report that something happened outside Selda (a form, an analysis, an ad response). Creates the lead if new, recognises it if known, records it on the timeline, and can put it on a campaign’s review list. Pass autoAdvance to have Selda write the reply from the Brain straight away and leave it in the Sales Inbox, draft.ready is published when it is there. It never sends. (needs inboundIntake) |
flows
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/flows | flows.list | The flows in a workspace: what runs when something arrives from outside, the steps in order, and whether each is switched on. Includes the workspace’s flow instruction files. |
POST | /v1/flows | flows.create | Create a flow: a trigger plus the steps to run when something arrives. Off unless you say otherwise. No step can send. |
DELETE | /v1/flows/{flowId} | flows.delete | Delete a flow and its run log. |
PATCH | /v1/flows/{flowId} | flows.update | Rewrite a flow’s name, trigger or steps. |
GET | /v1/flows/{flowId}/runs | flows.runs | What a flow actually did, run by run, step by step, including the steps that did nothing and why. |
POST | /v1/flows/{flowId}/set-enabled | flows.setEnabled | Switch a flow on or off. |
POST | /v1/flows/save-skill | flows.saveSkill | Write or rewrite an instruction file a flow step reads: how this business decides what an enquiry is. |
inbox
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/inbox/add-message | inbox.addMessage | Put one message into a lead’s Sales Inbox thread, in either direction, even for somebody who was never in a campaign. Creates the lead if it is new. This can send nothing. |
knowledge
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/knowledge | knowledge.get | What Selda knows about your business: the prose that grounds every message. |
POST | /v1/knowledge/append | knowledge.append | Add to what Selda knows about your business. |
POST | /v1/knowledge/set | knowledge.set | Replace what Selda knows about your business. |
leads
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/leads | leads.list | Leads in a workspace. |
POST | /v1/leads | leads.add | Add one company/contact. Pass analysis with research you already did and the message is written from it instead of a fresh crawl. |
DELETE | /v1/leads/{leadId} | leads.delete | Remove one lead. Deleting is the caller’s act, Selda never removes a lead on its own. |
GET | /v1/leads/{leadId} | leads.get | One lead in full: research, fit, outreach angle, notes. |
PATCH | /v1/leads/{leadId} | leads.update | Edit a lead’s fields, including its status. Org-scoped, so an API key can reach it. |
POST | /v1/leads/{leadId}/add-alias | leads.addAlias | Claim another email address for a lead, so a reply from it lands in the same conversation. Also adopts that address’s earlier unlinked inbound. |
POST | /v1/leads/{leadId}/add-tag | leads.addTag | Tag a lead. |
POST | /v1/leads/{leadId}/enrich | leads.enrich | Enrich one lead from a natural-language instruction. (live key only) |
POST | /v1/leads/{leadId}/skip | leads.skip | DELETES a lead and every message on it (legacy path, Clerk-authenticated, an API key cannot reach this; use leads.delete instead). |
PATCH | /v1/leads/{leadId}/status | leads.updateStatus | Set a lead’s status (legacy path, Clerk-authenticated, an API key cannot reach this; the MCP tool uses the org-scoped leads.update). |
POST | /v1/leads/add-batch | leads.addBatch | Add many companies/contacts in one call. |
POST | /v1/leads/delete-batch | leads.deleteBatch | Remove many leads. |
POST | /v1/leads/enrich-batch | leads.enrichBatch | Enrich many leads from a natural-language instruction. (live key only) |
POST | /v1/leads/merge | leads.merge | Merge duplicate leads. |
material
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/material/import | material.import | Your prospect folder → a campaign + company list, then it stops. Uploading material is not permission to send. |
messages
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/messages/{messageId}/approve | messages.approve | Approve a drafted message. Approval only. It does not send. |
GET | /v1/messages/by-lead | messages.byLead | The whole thread with one lead, sent and received. |
GET | /v1/messages/by-project | messages.byProject | Messages in a workspace. |
POST | /v1/messages/generate | messages.generate | Draft a message for a lead. |
projects
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/projects | projects.list | Your workspaces. Start here. Every other fn needs a projectId. |
GET | /v1/projects/{projectId} | projects.get | One workspace in full: business context, market analysis, ICP, settings. |
PATCH | /v1/projects/{projectId}/context | projects.updateContext | Rewrite a workspace’s business context. |
replies
| Method | Path | fn | What it does |
|---|---|---|---|
POST | /v1/replies/classify | replies.classify | Classify inbound replies. |
POST | /v1/replies/draft | replies.draft | Write a reply draft into a lead’s Sales Inbox thread. A person reviews and sends it in the app, this can send nothing. |
POST | /v1/replies/preview | replies.preview | Ask how Selda would answer an enquiry, from this workspace’s Brain, without creating a lead or storing a draft. Same writer the real reply uses, so tuning against this tunes the real thing. Stores nothing and sends nothing. |
runs
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/runs | runs.list | Every campaign run in a project, newest first, with its status. Use it to find a runId you no longer have. Runs the human archived are left out; pass includeArchived: true to see them too. |
POST | /v1/runs/{runId}/archive | runs.archive | Close a campaign run and take it off the active list. Keeps every contact and every message, deleting contacts stays a human act in the app. |
POST | /v1/runs/{runId}/confirm-companies | runs.confirmCompanies | Confirm a run’s company list so Selda finds the decision-makers and drafts the messages. Spends credits. Sends nothing, the send is still a human press in the app. (live key only) |
GET | /v1/runs/{runId}/leads | runs.leads | The companies a run found, each with the message Selda drafted for it. Nothing is sent. |
POST | /v1/runs/{runId}/rename | runs.rename | Give a campaign run a name a person would recognise. An empty name restores the derived title. |
GET | /v1/runs/{runId}/status | runs.status | Status of one campaign run: phase, companies found, contacts resolved, drafts written, errors. |
POST | /v1/runs/start-from-leads | runs.startFromLeads | Start a campaign from leads already pushed in with selda_add_lead, selected by the source label you gave them. No discovery, Selda writes a message per lead from the analysis that came with it, and stops at the drafts. (live key only) |
webhooks
| Method | Path | fn | What it does |
|---|---|---|---|
GET | /v1/webhooks | webhooks.list | Outbound webhook endpoints registered for this workspace. |
POST | /v1/webhooks | webhooks.create | Register an endpoint for events like reply.received. |
DELETE | /v1/webhooks/{webhookId} | webhooks.delete | Remove a webhook endpoint. |
material upload
| Method | Path | What it does |
|---|---|---|
POST | /v1/material/upload | Raw file bytes in, a storageId out. Send the file’s path in X-Selda-Path. Then hand the ids to material.import. |
What has no path, and why
Three registry entries get no REST route. Each is still callable exactly as it is today, in the RPC form, and nothing about them changed.
fn | Call it here instead | Why |
|---|---|---|
leads.enrich | POST /mcp/run | another registry table (INTERNAL_ACTIONS) declares leads.enrich on /mcp/run too and the dispatcher checks that one first, so this entry can never be the one that runs |
leads.update | POST /mcp/mutate | another registry table (INTERNAL_MUTATIONS) declares leads.update on /mcp/mutate too and the dispatcher checks that one first, so this entry can never be the one that runs |
replies.draft | POST /mcp/run | POST /v1/replies/draft is already the route for replies.draft on /mcp/mutate, and one URL cannot mean two functions. Call this one at POST /mcp/run with { "fn": "replies.draft" } |
And there is still no path that sends. launchRun is in no registry, so there is nothing for
a route to be generated from. A script can prepare a campaign completely and a person presses send
in the app.
Generated from convex/lib/mcpRegistry.ts. The arguments each path takes are on the function’s own
page under Reference / API functions.