API reference
Save people to the Leads table
Puts people in the user's Leads table, the way save_leads does on the assistant server. Give each person an id from mySDR (per_… from a search or POST /leads), or their name with linkedin_url and what you know: for someone you found yourself, and for people from POST /person/search and POST /person/enrich, which give no id (each match of POST /person/enrich carries these fields as save_input). Add a note on why; the user reads it in the table. Saving the same person again updates their row: what you send replaces the old value, what you leave out keeps it, and another employer replaces the old one as a whole. The Leads and Companies tables are what the user sees in mySDR. Your agent decides what goes in them: search results do not land there by themselves. The user keeps the overview there; your agent reads the same tables here and fills them with POST.
Each person can carry their state in the same call (status, next action, email, your own fields, campaign); a wrong value refuses the whole call and names the field. mySDR keeps your whole process, so the user never needs a spreadsheet next to it: the state of each company and person (status with a reason, next action, email and contact points, your own fields), every email, reply and bounce you log, the exclusions, and a check before every send. The user sees all of it in the Leads and Companies tabs.
Authentication
Send Authorization: Bearer gtk_…. Create the key under Connect assistant → API keys. Every gtk_ key can call these routes, also while the Signals API is closed. A missing, invalid or revoked key is 401.
Body parameters
leadsobject[]· required — 1 to 100 people.leads[].idstring— Aper_…from the Signals API (a search or POST /leads). When given, the other fields are ignored exceptnote.leads[].linkedin_urlstring— LinkedIn profile URL of someone you found yourself.leads[].namestring— Full name. Required withoutid.leads[].titlestring— Job title.leads[].headlinestring— LinkedIn headline.leads[].companystring— Employer name.leads[].company_linkedin_urlstring— LinkedIn page of the employer.leads[].company_domainstring— Website domain of the employer.leads[].locationstring— Where they are based.leads[].country_codestring— ISO 3166-1 alpha-2.leads[].why_nowstring— What happened that makes this person worth contacting now.leads[].notestring— Why you save this one, up to 500 characters. The user reads it in the table.leads[].statusstring—new(in the work),needs_decision(waits for the user; say why instatus_reason),snoozed(untilsnooze_until, thennewagain) orarchived(out; say why). One of:new,needs_decision,snoozed,archived.leads[].status_reasonstring | null— Why, in a few words: "Below 50 FTE", "Rescan 23/11". Up to 500 characters.leads[].snooze_untilstring | null— The day the row comes back asnew: a date (2026-11-23, 09:00 Brussels time) or an ISO time. Alone it snoozes the row.leads[].lead_statusstring | null— What they said.not_interestedalso stops LinkedIn for them and blocks a new sequence. One of:interested,meeting,later,not_interested.leads[].next_actionobject | null— Your next step and when:at(a date or ISO time),kind(follow_up,recontact,rescan,approval,other),note, andemailwhen that step is an email you will send yourself:{ "text", "subject", "step", "template", "to" }(textrequired, up to 20,000 characters;subjectup to 200 andtemplateup to 80, as in POST /outreach/activity;step1 to 20;toonly if it is not the row’s address;kinddefaults tofollow_up). The user sees the planned email on Templates, with the emails you already logged above it, and GET /agenda gives it back on its day, so you keep no copy. A list (GET /tables/leads, /tables/companies) gives it withouttext; one row (GET /tables/leads/{id}, /tables/companies/{id}), the response of a change and GET /agenda give the text. Loggingemail_sentwith the samestepclears it (logged without a step, it clears one planned without a step), and so do a reply, a meeting, a bounce, an unsubscribe, a referral andnot_interested; plan the next one. A newnext_actionreplaces the current one and the email planned for it (replacedin the response gives them back);nullclears both. GET /agenda lists the due ones.leads[].out_of_office_untilstring | null— Away until this day (from an out-of-office reply).leads[].emailstring | null— Their email address.leads[].email_statusstring | null— What verification said about that address. One of:verified,catch_all,unverifiable,not_found,bounced.leads[].fieldsobject— Your own columns, merged with what is there: a name in lowercase letters, digits and_(fte,ats,open_roles) with a value (text, number, true/false) or{ "value", "label", "source_url", "observed_at" };nullremoves one. Up to 40 per row. The user sees them as columns.leads[].campaign_idstring— Put the row in this campaign (POST /outreach/campaigns, or one from a LinkedIn sequence).leads[].campaign_idsstring[]— Replaces the campaigns of the row.leads[].stagestring | null— One of the organization's own stages (GET /stages), by its key (mql) or its name ("MQL");nullclears it. A stage sits next tostatus: it stops nothing and changes no status. A change is logged.leads[].ownerstring | null— Who in the team follows the row up:me(the user behind the key), or the email address or user id of a colleague (GET /team);nullclears it. A change is logged.
Response
addedinteger· required — New rows.updatedinteger· required — Rows that were there already. Saving again updates the row: what you send replaces the old value, what you leave out keeps it.skippedinteger· required — Items that were not saved.savedobject[]· required — Each saved item:ref(the id, LinkedIn URL or name you sent),id(what DELETE takes),nameandstatus(addedorupdated).not_savedobject[]· required — Each skipped item:ref,code(invalid_ref,not_found,no_name,duplicate,table_full) andreason.saved_by_assistantinteger· required — How many rows of this kind agents saved in total.dashboard_urlstring· required — The table in mySDR.stateobject[]— With a state in the body: per saved row itsidandstatus(set,unchanged,not_readyorfailed, with areason), andreplacedwhen a newnext_actionreplaced one (with its planned email).
Credits
Free. This call never uses credits.
Errors
- 400
invalid_request— Noleads, more than 100, an unknown field (the error names it inparam), a value that is too long, or a state that is not valid (paramnames it, e.g.leads[1].snooze_until). - 401
authentication_error— The Authorization header is missing, or the key is unknown or revoked. - 403
permission_error— API access is not part of your plan, or your account is suspended. - 429
rate_limit_error— More than 60 requests per minute, or more than 3 at the same time, on one key. Wait for the Retry-After header. - 503
service_unavailable— The API is temporarily unavailable. Retry later. - 500
internal_error— Something went wrong on our side.
Example
cURL
curl -X POST "https://mysdr.app/api/v1/tables/leads" \
-H "Authorization: Bearer $MYSDR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"leads": [
{
"id": "per_01J9Z6P8C8V0K3W6T2N8R5M1XB",
"note": "CFO at a company that raised a Series A last week."
},
{
"name": "Ann Example",
"linkedin_url": "https://www.linkedin.com/in/ann-example",
"company": "Gamma",
"note": "Posted about switching CRM."
}
]
}'
JavaScript
const response = await fetch('https://mysdr.app/api/v1/tables/leads', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MYSDR_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
"leads": [
{
"id": "per_01J9Z6P8C8V0K3W6T2N8R5M1XB",
"note": "CFO at a company that raised a Series A last week."
},
{
"name": "Ann Example",
"linkedin_url": "https://www.linkedin.com/in/ann-example",
"company": "Gamma",
"note": "Posted about switching CRM."
}
]
})
});
const body = await response.json();
Python
import json
import os
import urllib.request
payload = {
"leads": [
{
"id": "per_01J9Z6P8C8V0K3W6T2N8R5M1XB",
"note": "CFO at a company that raised a Series A last week."
},
{
"name": "Ann Example",
"linkedin_url": "https://www.linkedin.com/in/ann-example",
"company": "Gamma",
"note": "Posted about switching CRM."
}
]
}
request = urllib.request.Request(
"https://mysdr.app/api/v1/tables/leads",
headers={
"Authorization": f"Bearer {os.environ['MYSDR_API_KEY']}",
"Content-Type": "application/json"
},
data=json.dumps(payload).encode(),
method="POST",
)
with urllib.request.urlopen(request) as response:
body = json.load(response)
Example response
{
"added": 2,
"updated": 0,
"skipped": 0,
"saved": [
{
"ref": "per_01J9Z6P8C8V0K3W6T2N8R5M1XB",
"id": "per_01J9Z6P8C8V0K3W6T2N8R5M1XB",
"name": "Jane Example",
"status": "added"
},
{
"ref": "https://www.linkedin.com/in/ann-example",
"id": "sav_7c1f2a40-5b0e-4c3e-9a71-2f7d3e0c9b11",
"name": "Ann Example",
"status": "added"
}
],
"not_saved": [],
"saved_by_assistant": 14,
"dashboard_url": "https://mysdr.app/en/gtm/leads"
}