Connect your app

Connect your app

One endpoint pattern, one auth header, no SDK required. Works from any backend, any language. Push data in, read your pipeline out, receive events back when things happen.

Quickstart (3 min)

1. Get your key. Settings → Integrations → Connect your app → Create a key.

SELDA_API_KEY=sk_live_...
SELDA_PROJECT_ID=...

2. Push a lead:

curl -X POST https://api.selda.ai/mcp/mutate \
  -H "Authorization: Bearer $SELDA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fn": "leads.add",
    "args": {
      "projectId": "'"$SELDA_PROJECT_ID"'",
      "company": "Acme Oy",
      "email": "owner@acme.fi",
      "companyDomain": "acme.fi",
      "source": "my-app"
    }
  }'
{ "value": { "leadId": "...", "duplicate": false } }

Selda dedupes by email, re-running is safe. That’s the whole integration.

Using an AI tool? Hit “Copy setup prompt” in the app and paste it into Claude Code / Cursor / Lovable with your product, it wires the integration for you.


Four integration patterns

1. Push data to Selda

Send leads from any backend event or cron job:

await fetch("https://api.selda.ai/mcp/mutate", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SELDA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    fn: "leads.add",
    args: {
      projectId: process.env.SELDA_PROJECT_ID,
      company: "Acme Oy",
      email: "owner@acme.fi",
      companyDomain: "acme.fi",
      notes: "views:1200 · no_video:true",
      source: "my-app",
    },
  }),
});

Or hand Selda a campaign brief and let the engine find decision-makers:

# 1. Create campaign
POST /mcp/mutate  { "fn": "campaigns.create", "args": { "projectId": "...", "name": "Q3 push", "leadCount": 30 } }
 
# 2. Run engine (finds leads, writes messages, waits for approval, nothing sends automatically)
POST /mcp/run     { "fn": "engine.start", "args": { "projectId": "..." } }

Push a lead with your own analysis

If you already researched the company (for example, Claude wrote a per-company analysis), pass it in analysis. Selda uses it as the authoritative research grounding: it writes the outreach from your analysis instead of crawling from scratch, and labels the draft “Based on your analysis” in the app. This is the Claude × Selda loop, you supply the depth, Selda finds the contact, writes the message, and holds it for your approval.

await fetch("https://api.selda.ai/mcp/mutate", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SELDA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    fn: "leads.add",
    args: {
      projectId: process.env.SELDA_PROJECT_ID,
      company: "Acme Oy",
      companyDomain: "acme.fi",
      // Your write-up (markdown or plain text). The message stays faithful to it. // Selda never invents specifics beyond what you provide here.
      analysis: "Acme builds industrial IoT sensors. Opened a Munich office in Q1 2026 " +
                "and is hiring field engineers, a clear expansion signal to lead with.",
      source: "claude",
    },
  }),
});

Via the hosted MCP server the same lands as the analysis parameter on the selda_add_lead tool. Then run the engine (or add the lead to a campaign): it finds the right contact, grounds the hook and message on your analysis, and waits for your approval before anything sends.

Faithful by design. The analysis text is treated as grounded source, so the message can reference its facts, but Selda never fabricates beyond it. Keep it factual and the outreach stays accurate.

2. Read your pipeline

curl -X POST https://api.selda.ai/mcp/query \
  -H "Authorization: Bearer $SELDA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fn":"campaigns.stats","args":{"campaignId":"..."}}'
# → { "value": { "sent": 42, "replies": 7, "meetings": 2 } }

Full query list: Read reference below.

3. Receive events from Selda (outbound webhooks)

Register a URL and Selda POSTs a signed payload whenever something happens.

Register (or: Settings → Integrations → Outbound webhooks → Add):

curl -X POST https://api.selda.ai/mcp/mutate \
  -H "Authorization: Bearer $SELDA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fn": "webhooks.create",
    "args": {
      "url": "https://your-app.com/selda/events",
      "events": ["reply.received", "lead.status_changed"],
      "description": "My app"
    }
  }'
# → { "value": { "webhookId": "...", "secret": "whsec_..." } }

Available events:

EventWhen
lead.addedA lead is created via the API (leads.add / leads.addBatch)
reply.receivedAn inbound reply arrives in the Sales Inbox
message.sentA message is sent to a lead (campaign sends and API sends)
lead.status_changedLead status changes to responded / qualified, or the CRM stage moves (contactedrepliedmeetingcustomer / lost)
campaign.completedA campaign run finishes sending
credits.lowCredit balance drops below what one run costs, fires once per crossing, before a campaign runs out mid-send. Read the exact number from credits.info — it is derived from measured cost, not a fixed 20
meeting.bookedA prospect books a meeting through Selda
test.pingSent by the “Send test” button / sendTest, verify your endpoint

Subscribe with "events": ["*"] to receive everything.

Payload, every delivery POSTs { event, timestamp, data } with two headers: X-Selda-Event (the event name) and X-Selda-Signature (see below). Example for reply.received:

{
  "event": "reply.received",
  "timestamp": 1720000000000,
  "data": {
    "inboundMessageId": "...",
    "leadId": "...",
    "email": "owner@acme.fi",
    "company": "Acme Oy",
    "campaignId": "...",
    "from": "owner@acme.fi",
    "subject": "Re: Quick question",
    "classification": "positive"
  }
}

Verify the signature (Node.js):

const crypto = require('crypto')
 
function verifySeldaWebhook(secret, rawBody, signatureHeader) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
}
 
// Express
app.post('/selda/events', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySeldaWebhook(
    process.env.SELDA_WEBHOOK_SECRET,
    req.body,
    req.headers['x-selda-signature']
  )) return res.status(401).send('Bad signature')
 
  const { event, data } = JSON.parse(req.body)
  // handle...
  res.sendStatus(200)
})

Delivery guarantees: 10-second timeout per request. On a non-2xx response or network error Selda retries up to 2 more times (30 seconds, then 5 minutes after the first attempt). Retries carry a byte-identical body and signature. After 10 consecutive deliveries where all attempts fail, the endpoint is auto-disabled (active: false, disabledAt set), the state is visible via webhooks.list and in Settings → Outbound webhooks, where you can re-enable the endpoint once your receiver is fixed.

4. Pull from your app (data connectors)

Selda polls your HTTP JSON endpoint and syncs records as leads automatically.

Register (or: Settings → Integrations → Apps → “Connect your API”):

curl -X POST https://api.selda.ai/mcp/mutate \
  -H "Authorization: Bearer $SELDA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fn": "connectors.create",
    "args": {
      "projectId": "...",
      "name": "My App",
      "url": "https://api.myapp.com/customers",
      "authHeader": "Bearer sk_...",
      "fieldMap": {
        "company": "companyName",
        "email": "ownerEmail",
        "companyDomain": "website",
        "notes": "city"
      }
    }
  }'

Your endpoint must return a JSON array. Selda maps fields via fieldMap, dedupes by email, polls on a schedule.


Update workspace knowledge and playbooks

Push context into Selda so the engine writes better, more targeted messages.

Set workspace knowledge (what your product is, what you sell, ICP):

# Append to existing knowledge
POST /mcp/mutate
{
  "fn": "knowledge.append",
  "args": {
    "projectId": "...",
    "text": "We sell media packages: video production 990€, distribution to 14k daily readers. Target: terrace restaurants without a video yet."
  }
}
fnDoes
knowledge.getRead current knowledge notes
knowledge.setReplace the whole knowledge field
knowledge.appendAppend text (keeps existing content)

Update a campaign playbook mid-run:

POST /mcp/mutate
{
  "fn": "campaign.updatePlaybook",
  "args": {
    "runId": "...",
    "appendText": "Prioritise restaurants with views > 1000 but no video yet."
  }
}

runId comes from campaigns.list → each campaign’s runs array.


API reference

Authentication

Authorization: Bearer sk_live_...

Your org is resolved from the key, never send orgId.

EndpointScope required
POST /mcp/queryread
POST /mcp/mutatewrite
POST /mcp/runpipeline

Missing scope → 403 { "error": "Missing 'write' scope" }.

Write. POST /mcp/mutate

Leads

fnKey argsDoes
leads.addprojectId, company, email, companyDomain, firstName, lastName, jobTitle, linkedinUrl, phone, notes, source, tags[]Add a lead. Dedupes by email. Triggers auto-routing rules.
leads.addBatchprojectId, leads[] (max 500)Batch insert. Returns [{ leadId, duplicate }]. Triggers auto-routing per lead.
leads.updateleadId + any lead fieldsPatch lead fields. Only provided fields are updated.
leads.updateStatusleadId, statusSet status: new / contacted / responded / qualified / unqualified.
leads.addTagleadId, tags[]Add tags (merges, no duplicates).
leads.archiveleadIdSoft-delete a lead.

Campaigns

fnKey argsDoes
campaigns.createprojectId, name, outreachAngle, channels[], targetRole, targetIndustry, targetCompanySize, locations[], leadCount, startAt?Create a campaign. startAt (unix ms) schedules the engine start.
campaigns.updatecampaignId, name?, description?Rename or update description.
campaigns.deletecampaignIdSoft-delete (blocks if active run).
campaigns.addLeadscampaignId, leadIds[]Attach leads to a campaign.
campaigns.pauserunIdPause a running engine run.
campaigns.resumerunIdResume a paused run.
campaigns.addRuleprojectId, campaignId, condition: { tag }Auto-route: leads tagged with this go to this campaign automatically.
campaigns.deleteRuleruleIdRemove an auto-routing rule.
campaigns.sendWindowcampaignId, sendWindow: { days, startHour, endHour }, maxPerDay?Set sending schedule. days: ["monday","tuesday",...].

Messages

fnKey argsDoes
messages.approvemessageId, sendAt?Approve for sending. Pass sendAt (unix ms) to schedule.
messages.approveAllprojectId, campaignId?, sendAt?Approve all pending drafts (optionally for one campaign).
messages.rejectmessageId, reason?Reject a draft.
messages.editmessageId, body?, subject?Edit draft body/subject before sending.

Replies

fnKey argsDoes
replies.markHandledreplyIdMark an inbound reply as handled.

Knowledge & playbooks

fnKey argsDoes
knowledge.setprojectId, textReplace workspace knowledge.
knowledge.appendprojectId, textAppend to workspace knowledge.
campaign.setKnowledgecampaignId, textReplace campaign knowledge + update latest run’s playbook.
campaign.appendKnowledgecampaignId, textAppend campaign knowledge.
campaign.updatePlaybookrunId, playbook?, appendText?Replace or append to the campaign brief.

Projects, connectors, webhooks

fnKey argsDoes
projects.createname, websiteUrl, descriptionCreate a workspace.
projects.updateContextprojectId + context fieldsUpdate ICP, voice, competitors.
connectors.createprojectId, name, url, authHeader?, fieldMapRegister a data connector (Selda polls your API).
connectors.deleteconnectorIdRemove a connector.
webhooks.createurl, events[], description?Register an outbound webhook. Returns secret once.
webhooks.deletewebhookIdRemove a webhook.

Read. POST /mcp/query

fnArgsReturns
projects.list.Your projects
projects.getprojectIdOne project
leads.listprojectId, limit?, cursor?, tags[]?, status?Leads (paginated, filterable by tag + status)
leads.getleadIdOne lead
leads.statsprojectId{ total, byStatus, byTag }, counts grouped by status and tag
campaigns.listprojectIdCampaigns
campaigns.getcampaignIdOne campaign
campaigns.statscampaignId{ sent, replies, meetings }
campaigns.statsDetailedcampaignId{ total, byStatus, byDay: [{ date, sent, replied }] }, daily breakdown
campaigns.sendWindowcampaignIdSend window config + maxPerDay
campaigns.rules.listprojectIdActive auto-routing rules
messages.listprojectId, status?, campaignId?, limit?Messages filtered by status and/or campaign
messages.byProjectprojectIdAll messages and drafts
messages.byLeadleadIdThread for one lead
replies.listprojectId, status?, campaignId?, limit?, cursor?Inbound replies (paginated)
credits.info.Remaining credits
connectors.listprojectIdActive data connectors
webhooks.list.Registered outbound webhooks
knowledge.getprojectIdCurrent knowledge notes

Run. POST /mcp/run

fnDoes
engine.startFull discovery → research → hook → message pipeline. Pass startAt (unix ms) to delay start. It stops at the company list and waits for a person to confirm it in the app — poll runs.status and read awaitingHuman.
company.lookupEnrich a single company
messages.generateDraft a message
messages.sendSend a message
replies.classifyClassify an inbound reply
replies.draftDraft a reply
connectors.syncForce-sync a data connector now

Errors

StatusMeaning
401Missing or revoked key
403Key lacks required scope
400Unknown fn
500Function threw (e.g. project not found)

End-to-end example: terassikesä

New restaurant signs up → Selda reaches out → reply fires back to your app.

1. New restaurant on terassikesa.fi
   → POST /mcp/mutate  fn=leads.add
     { company: "Huvila Ranta", email: "jani@huvila.fi", notes: "views:1200 · no_video:true" }

2. Selda engine runs, finds contact, writes personalised message, waits for approval

3. You approve in the Selda app → Selda sends

4. Jani replies → Selda fires webhook:
   POST https://adventurous-bear-355.eu-west-1.convex.site/webhooks/selda
   {
     "event": "reply.received",
     "data": {
       "company": "Huvila Ranta",
       "email": "jani@huvila.fi",
       "classification": "positive",
       "campaignId": "..."
     }
   }

5. Your backend marks the restaurant as a hot lead
terassikesä fieldSelda field
restaurantNamecompany
websitecompanyDomain
ownerEmailemail
ownerFirstNamefirstName
ownerLastNamelastName
citynotes

Source of truth: convex/mcpApi.ts. See Security for key and webhook secret handling.