Examples
Every example on this page is a complete, runnable call. Set two variables and paste.
SELDA_API_KEY=sk_test_... # Settings → Apps → Selda MCP. Test keys cannot send anything.
SELDA_PROJECT_ID=...All of it is also a repository you can clone: Selda-AI/selda-examples.
Add one lead
The most common call there is. Selda dedupes by email, so running it twice is safe.
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": "Example Oy",
"email": "owner@example.fi",
"companyDomain": "example.fi",
"source": "my-app"
}
}'The response always has the same four fields, so your code never has to branch on which path it took:
{ "value": { "leadId": "k57f...", "duplicate": false, "blocked": false, "reason": null } }blocked: true means the address asked Selda to stop. Nothing was created, and pushing it again
will not change that. Read reason and move on.
Add many at once
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fn": "leads.addBatch",
"args": {
"projectId": "'"$SELDA_PROJECT_ID"'",
"leads": [
{ "company": "Example Oy", "email": "owner@example.fi", "companyDomain": "example.fi" },
{ "company": "Second Example Ltd", "email": "hello@example.co.uk", "companyDomain": "example.co.uk" }
]
}
}'Up to 500 per call. You get back one { leadId, duplicate } per row, in order.
Something happened: a form, a trial, a download
leads.add says this person exists. events.ingest says this happened, which is usually what
you actually have. One call, and Selda works out whether this is somebody new, somebody it already
knows, or an address that asked to be left alone.
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fn": "events.ingest",
"args": {
"projectId": "'"$SELDA_PROJECT_ID"'",
"type": "form_submitted",
"identity": {
"email": "matti@example.fi",
"name": "Matti Meikalainen",
"company": "Example Oy"
},
"payload": { "form": "contact", "message": "What does it cost?" },
"source": "example.com",
"idempotencyKey": "contact-matti@example.fi-2026-08-21",
"autoAdvance": true
}
}'idempotencyKey is what makes a retry safe. Derive it from the submission, never from a random
value: the same key replays instead of creating a second lead for the same person.
autoAdvance: true makes Selda read your Brain and write the reply, in the language the enquiry
was written in, and leave it in the Sales Inbox. It spends credits and it never sends. Leave it
out and the lead is simply stored.
If the workspace has no Brain material to answer from, nothing is drafted and you get
no_brain_material. That is deliberate: a quote answered from an empty Brain is an invented price.
Full argument list: inbound reference.
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 } }Other reads you will want: leads.list (paginated, filter by tag and status), leads.stats,
campaigns.statsDetailed (daily breakdown), messages.list, replies.list, credits.info.
All of them.
Run a campaign from a brief
# 1. Create the campaign
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" -H "Content-Type: application/json" \
-d '{"fn":"campaigns.create","args":{"projectId":"'"$SELDA_PROJECT_ID"'","name":"Q3 push","leadCount":30}}'
# 2. Start the engine
curl -X POST https://api.selda.ai/mcp/run \
-H "Authorization: Bearer $SELDA_API_KEY" -H "Content-Type: application/json" \
-d '{"fn":"engine.start","args":{"projectId":"'"$SELDA_PROJECT_ID"'"}}'
# 3. Poll, and read awaitingHuman before you say it finished
curl -X POST https://api.selda.ai/mcp/query \
-H "Authorization: Bearer $SELDA_API_KEY" -H "Content-Type: application/json" \
-d '{"fn":"runs.status","args":{"runId":"..."}}'The engine stops at the company list and waits for a person to confirm it in the app. That is not a timeout, it is the design. Running it costs credits, so do not put it in a loop.
One complete flow
A restaurant signs up on your site, Selda reaches out, the reply comes back to your backend.
1. New restaurant on your site
POST /mcp/mutate fn=leads.add
{ company: "Example Restaurant", email: "owner@example.fi", notes: "views:1200 · no_video:true" }
2. Selda finds the contact, researches, writes a personalised message
3. A human approves it in the Selda app, and Selda sends
4. Jani replies, and Selda POSTs your endpoint:
{ "event": "reply.received",
"data": { "company": "Example Restaurant", "email": "owner@example.fi",
"classification": "positive", "campaignId": "..." } }
5. Your backend marks the restaurant as a hot leadMapping your fields onto Selda’s is usually the only design work:
| Your field | Selda field |
|---|---|
restaurantName | company |
website | companyDomain |
ownerEmail | email |
ownerFirstName | firstName |
ownerLastName | lastName |
city | notes |
Step 4 needs a webhook: how to register and verify one.
Teach Selda about your business
The engine writes better when it knows what you sell. This is the same Brain the app edits.
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fn": "knowledge.append",
"args": {
"projectId": "'"$SELDA_PROJECT_ID"'",
"text": "We sell media packages: video production 990 EUR, distribution to 14k daily readers. Target: terrace restaurants without a video yet."
}
}'knowledge.get reads it, knowledge.set replaces the whole thing, knowledge.append adds to it.
To steer a campaign that is already running, campaign.updatePlaybook with appendText.
When something goes wrong
| Status | What it means | What to do |
|---|---|---|
401 | Missing or revoked key | Check the header, make a new key |
403 | The key lacks the scope | read for query, write for mutate, pipeline for run |
400 | Unknown fn | Check the spelling against the reference |
429 | Too many calls | Wait and retry. The limit is per key |
500 | The function threw | The message says what, usually a bad id |
A refusal always carries a reason. blocked: true on a lead and a 429 are different problems and
only one of them is worth retrying.